@bitkyc08/opencodex 2.39.0 → 2.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { readCodexTokens } from "./auth-collision";
5
5
  import {
@@ -19,6 +19,8 @@ import { resolveCodexHomeDir } from "./home";
19
19
  import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
20
20
  import { clearAccountNeedsReauth } from "./account-runtime-state";
21
21
  import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
22
+ import { withNativeMainExclusiveClaim } from "./native-main-claim";
23
+ import { resolveNativeProfileContext } from "./native-profile-store";
22
24
 
23
25
  export { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
24
26
 
@@ -35,6 +37,16 @@ let beforeMainAuthJsonRenameForTests: (() => void) | null = null;
35
37
  type MainAuthJsonCredential = {
36
38
  path: string;
37
39
  rawSha256: string;
40
+ /**
41
+ * Filesystem identity of the file the hash was taken from (#2999).
42
+ *
43
+ * A content hash cannot tell "unchanged" from "replaced with a file that happens to
44
+ * hash the same", and more importantly it is read at a different instant than the
45
+ * rename. Carrying dev+ino lets the pre-rename guard ask the sharper question: is this
46
+ * still the same file, not merely one with the same bytes. `null` when the target could
47
+ * not be stat'ed, which is treated as "cannot prove identity" rather than "matches".
48
+ */
49
+ identity: { dev: number; ino: number } | null;
38
50
  root: Record<string, unknown>;
39
51
  tokens: Record<string, unknown>;
40
52
  accessToken?: string;
@@ -71,6 +83,22 @@ function sha256(value: string): string {
71
83
  return createHash("sha256").update(value).digest("hex");
72
84
  }
73
85
 
86
+ /**
87
+ * Filesystem identity of a path, or null when it cannot be read.
88
+ *
89
+ * Null is deliberately NOT "matches anything": a caller that cannot prove identity must
90
+ * fail closed, because the whole point here is refusing to overwrite a file we can no
91
+ * longer vouch for.
92
+ */
93
+ function statIdentity(path: string): { dev: number; ino: number } | null {
94
+ try {
95
+ const stat = statSync(path);
96
+ return { dev: Number(stat.dev), ino: Number(stat.ino) };
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
74
102
  function readMainAuthJsonCredential(): MainAuthJsonCredential | null {
75
103
  const path = resolveWriteTarget(join(resolveCodexHomeDir(), "auth.json"));
76
104
  let raw: string;
@@ -96,6 +124,7 @@ function readMainAuthJsonCredential(): MainAuthJsonCredential | null {
96
124
  return {
97
125
  path,
98
126
  rawSha256: sha256(raw),
127
+ identity: statIdentity(path),
99
128
  root,
100
129
  tokens,
101
130
  ...(accessToken ? { accessToken } : {}),
@@ -129,6 +158,21 @@ function assertMainAuthJsonSnapshotUnchanged(expected: MainAuthJsonCredential):
129
158
  if (!current || current.path !== expected.path || current.rawSha256 !== expected.rawSha256) {
130
159
  throw new MainAuthJsonChangedDuringRefreshError();
131
160
  }
161
+ // Identity, not just content (#2999). A writer can land between this check and the
162
+ // rename, and rename(2) replaces unconditionally - so the narrower the question asked
163
+ // here, the smaller the window where a Codex login gets silently overwritten. An
164
+ // unreadable identity on either side fails closed: unprovable is not the same as equal.
165
+ assertMainAuthJsonIdentityUnchanged(expected);
166
+ }
167
+
168
+ function assertMainAuthJsonIdentityUnchanged(expected: MainAuthJsonCredential): void {
169
+ const identity = statIdentity(expected.path);
170
+ if (!identity
171
+ || !expected.identity
172
+ || identity.dev !== expected.identity.dev
173
+ || identity.ino !== expected.identity.ino) {
174
+ throw new MainAuthJsonChangedDuringRefreshError();
175
+ }
132
176
  }
133
177
 
134
178
  function persistRefreshedMainAuthJson(
@@ -158,6 +202,9 @@ function persistRefreshedMainAuthJson(
158
202
  beforeMainAuthJsonRenameForTests = null;
159
203
  hook?.();
160
204
  },
205
+ // Runs immediately before rename(2), after the test hook has had its chance to
206
+ // simulate an external writer. Full snapshot check (content AND identity): this is
207
+ // the last look we get, so it asks everything it can rather than the cheap question.
161
208
  validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected),
162
209
  },
163
210
  );
@@ -187,41 +234,66 @@ async function resolveMainAccountToken(
187
234
  : null;
188
235
  }
189
236
 
237
+ const refreshTimeout = AbortSignal.timeout(30_000);
190
238
  const signal = dependencies.signal
191
- ? AbortSignal.any([dependencies.signal, AbortSignal.timeout(30_000)])
192
- : AbortSignal.timeout(30_000);
239
+ ? AbortSignal.any([dependencies.signal, refreshTimeout])
240
+ : refreshTimeout;
193
241
  const lockKey = refreshGrantFingerprintForToken(initial.refreshToken);
194
- return withCodexRefreshFileLock(lockKey, signal, async () => {
195
- const locked = readMainAuthJsonCredential();
196
- if (!locked) throw new MainAuthJsonChangedDuringRefreshError();
197
- if (!locked.refreshToken
198
- || refreshGrantFingerprintForToken(locked.refreshToken) !== lockKey) {
199
- if (locked.accessToken !== rejectedAccessToken
200
- && mainAccessTokenFresh(locked.accessToken, Date.now(), 0)) {
201
- return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
202
- }
203
- throw new MainAuthJsonChangedDuringRefreshError();
204
- }
205
- if (locked.accessToken !== rejectedAccessToken
206
- && mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) {
207
- return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
208
- }
209
- const refresh = dependencies.refreshToken
210
- ?? ((refreshToken: string, options: { signal: AbortSignal }) => refreshChatGPTToken(refreshToken, options));
211
- let refreshed: OAuthCredentials;
212
- try {
213
- refreshed = await refresh(locked.refreshToken, { signal });
214
- } catch (cause) {
215
- const message = cause instanceof Error ? cause.message.toLowerCase() : "";
216
- const reason = /invalid_grant|invalidated|revoked|expired/.test(message)
217
- ? "reauth" as const
218
- : "transient" as const;
219
- throw new MainAccountTokenRefreshError(reason, { cause });
242
+ // Two locks, because they guard two different things that live in two different
243
+ // homes. `withCodexRefreshFileLock` is keyed on the grant fingerprint and lives
244
+ // under OPENCODEX_HOME; it serializes refreshes of the SAME grant within one
245
+ // install. The file being rewritten is `auth.json` under CODEX_HOME, which every
246
+ // OpenCodex install on the machine shares no matter what its own home is -- so two
247
+ // proxies with distinct OPENCODEX_HOMEs took two unrelated fingerprint locks and
248
+ // refreshed the one credential concurrently (#2999).
249
+ //
250
+ // The outer claim is the CODEX_HOME coordination the other native-main paths
251
+ // already use (`.opencodex-native-main.claim.sqlite`), so this needs no new
252
+ // primitive and no FFI. Order is claim (machine-wide) then fingerprint lock
253
+ // (per-grant), never the reverse: two processes holding different fingerprint
254
+ // locks and then reaching for the same claim would deadlock.
255
+ try {
256
+ return await withNativeMainExclusiveClaim(
257
+ resolveNativeProfileContext(),
258
+ () => withCodexRefreshFileLock(lockKey, signal, async () => {
259
+ const locked = readMainAuthJsonCredential();
260
+ if (!locked) throw new MainAuthJsonChangedDuringRefreshError();
261
+ if (!locked.refreshToken
262
+ || refreshGrantFingerprintForToken(locked.refreshToken) !== lockKey) {
263
+ if (locked.accessToken !== rejectedAccessToken
264
+ && mainAccessTokenFresh(locked.accessToken, Date.now(), 0)) {
265
+ return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
266
+ }
267
+ throw new MainAuthJsonChangedDuringRefreshError();
268
+ }
269
+ if (locked.accessToken !== rejectedAccessToken
270
+ && mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) {
271
+ return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
272
+ }
273
+ const refresh = dependencies.refreshToken
274
+ ?? ((refreshToken: string, options: { signal: AbortSignal }) => refreshChatGPTToken(refreshToken, options));
275
+ let refreshed: OAuthCredentials;
276
+ try {
277
+ refreshed = await refresh(locked.refreshToken, { signal });
278
+ } catch (cause) {
279
+ const message = cause instanceof Error ? cause.message.toLowerCase() : "";
280
+ const reason = /invalid_grant|invalidated|revoked|expired/.test(message)
281
+ ? "reauth" as const
282
+ : "transient" as const;
283
+ throw new MainAccountTokenRefreshError(reason, { cause });
284
+ }
285
+ const result = persistRefreshedMainAuthJson(locked, refreshed);
286
+ clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
287
+ return result;
288
+ }),
289
+ { waitMs: 30_000, signal },
290
+ );
291
+ } catch (cause) {
292
+ if (refreshTimeout.aborted && !dependencies.signal?.aborted) {
293
+ throw new MainAccountTokenRefreshError("transient", { cause });
220
294
  }
221
- const result = persistRefreshedMainAuthJson(locked, refreshed);
222
- clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
223
- return result;
224
- });
295
+ throw cause;
296
+ }
225
297
  }
226
298
 
227
299
  /** Refresh the CLI-owned native credential before upstream I/O and publish it atomically. */
@@ -17,6 +17,7 @@ export const NATIVE_MAIN_CLAIM_DB = ".opencodex-native-main.claim.sqlite";
17
17
  export interface NativeMainClaimOptions {
18
18
  waitMs?: number;
19
19
  pollMs?: number;
20
+ signal?: AbortSignal;
20
21
  hardenPath?: (path: string) => Promise<void>;
21
22
  platform?: NodeJS.Platform;
22
23
  env?: NodeJS.ProcessEnv;
@@ -121,6 +122,23 @@ function releaseClaim(database: Database | undefined, file: StableLockFile | und
121
122
  try { file?.close(); } catch { /* operation already completed */ }
122
123
  }
123
124
 
125
+ function waitForClaimRetry(ms: number, signal?: AbortSignal): Promise<void> {
126
+ if (!signal) return Bun.sleep(ms);
127
+ if (signal.aborted) return Promise.reject(signal.reason);
128
+ return new Promise((resolve, reject) => {
129
+ const timer = setTimeout(() => {
130
+ signal.removeEventListener("abort", onAbort);
131
+ resolve();
132
+ }, ms);
133
+ const onAbort = () => {
134
+ clearTimeout(timer);
135
+ signal.removeEventListener("abort", onAbort);
136
+ reject(signal.reason);
137
+ };
138
+ signal.addEventListener("abort", onAbort, { once: true });
139
+ });
140
+ }
141
+
124
142
  export async function withNativeMainSharedClaim<T>(
125
143
  context: NativeProfileContext,
126
144
  operation: () => Promise<T>,
@@ -151,9 +169,11 @@ export async function withNativeMainExclusiveClaim<T>(
151
169
  operation: () => Promise<T>,
152
170
  options: NativeMainClaimOptions = {},
153
171
  ): Promise<T> {
172
+ const signal = options.signal;
154
173
  const deadline = Date.now() + Math.max(0, options.waitMs ?? 0);
155
174
  const pollMs = Math.max(1, options.pollMs ?? 50);
156
175
  for (;;) {
176
+ if (signal?.aborted) throw signal.reason;
157
177
  let database: Database | undefined;
158
178
  let file: StableLockFile | undefined;
159
179
  try {
@@ -162,14 +182,16 @@ export async function withNativeMainExclusiveClaim<T>(
162
182
  assertStableLockFile(nativeMainClaimPath(context), file);
163
183
  } catch (error) {
164
184
  releaseClaim(database, file);
185
+ if (signal?.aborted) throw signal.reason;
165
186
  const mapped = mapClaimSetupError(error, "Native-main credentials are in use.");
166
187
  if (mapped.code === "NATIVE_MAIN_CLAIM_BUSY" && Date.now() < deadline) {
167
- await Bun.sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
188
+ await waitForClaimRetry(Math.min(pollMs, Math.max(1, deadline - Date.now())), signal);
168
189
  continue;
169
190
  }
170
191
  throw mapped;
171
192
  }
172
193
  try {
194
+ if (signal?.aborted) throw signal.reason;
173
195
  return await operation();
174
196
  } finally {
175
197
  releaseClaim(database, file);
@@ -133,6 +133,13 @@ function resolveRegularFile(path: string): PathResult {
133
133
  function readRegularFile(path: string): ReadResult {
134
134
  const resolved = resolveRegularFile(path);
135
135
  if (resolved.kind !== "path") return resolved;
136
+ // Root can read a chmod(000) file on Linux, which made the residue verdict
137
+ // depend on who ran the suite. No read bit means the configured surface is
138
+ // operationally unreadable to an ordinary Codex process and must remain
139
+ // indeterminate even when the inspector itself has elevated privileges.
140
+ if (process.platform !== "win32" && (resolved.stat.mode & 0o444) === 0) {
141
+ return { kind: "indeterminate", reason: "EACCES: surface has no read permission bits" };
142
+ }
136
143
  try {
137
144
  const content = readFileSync(resolved.path, "utf8");
138
145
  const after = statSync(resolved.path);
@@ -0,0 +1,237 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { atomicWriteFile } from "../config/atomic-write";
5
+ import { getConfigDir } from "../config/paths";
6
+ import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks";
7
+ import type { OcxConfig } from "../types";
8
+
9
+ /**
10
+ * Opt-in auto-redemption of a Codex reset credit shortly before it expires (#822).
11
+ *
12
+ * Default off. When enabled, the nearest unexpired credit for the main Codex account is
13
+ * redeemed `leadTimeMinutes` before its `expires_at`. Every fire re-reads the upstream credit
14
+ * list first and dispatches only when the same credit (granted_at + expires_at) is still
15
+ * present, so a credit the operator already spent by hand is never redeemed twice. The
16
+ * `redeem_request_id` for a credit identity is minted once and journaled to disk before the
17
+ * consume call, so a crash between dispatch and settle replays the same idempotent request
18
+ * instead of spending a second credit. Logs carry a hashed account key only.
19
+ */
20
+
21
+ export interface ResetCreditAutoRedeemSettings {
22
+ enabled: boolean;
23
+ leadTimeMinutes: number;
24
+ }
25
+
26
+ export const DEFAULT_LEAD_TIME_MINUTES = 10;
27
+ export const MIN_LEAD_TIME_MINUTES = 1;
28
+ export const MAX_LEAD_TIME_MINUTES = 60;
29
+
30
+ export function resolveResetCreditAutoRedeemSettings(config: Pick<OcxConfig, "resetCreditAutoRedeem">): ResetCreditAutoRedeemSettings {
31
+ const raw = config.resetCreditAutoRedeem;
32
+ if (!raw || raw.enabled !== true) return { enabled: false, leadTimeMinutes: DEFAULT_LEAD_TIME_MINUTES };
33
+ const lead = typeof raw.leadTimeMinutes === "number" && Number.isInteger(raw.leadTimeMinutes)
34
+ ? Math.min(Math.max(raw.leadTimeMinutes, MIN_LEAD_TIME_MINUTES), MAX_LEAD_TIME_MINUTES)
35
+ : DEFAULT_LEAD_TIME_MINUTES;
36
+ return { enabled: true, leadTimeMinutes: lead };
37
+ }
38
+
39
+ export interface ResetCredit {
40
+ granted_at: string;
41
+ expires_at: string;
42
+ }
43
+
44
+ export interface AutoRedeemPlan {
45
+ /** Stable identity of the credit being protected. */
46
+ grantedAt: string;
47
+ expiresAt: string;
48
+ /** Epoch ms at which the redeem should be attempted. */
49
+ dueAt: number;
50
+ }
51
+
52
+ /** Pick the credit that expires soonest and is still in the future; null when nothing qualifies. */
53
+ export function planAutoRedeem(now: number, credits: readonly ResetCredit[], settings: ResetCreditAutoRedeemSettings): AutoRedeemPlan | null {
54
+ if (!settings.enabled) return null;
55
+ let best: AutoRedeemPlan | null = null;
56
+ for (const credit of credits) {
57
+ const expires = Date.parse(credit.expires_at);
58
+ if (!Number.isFinite(expires) || expires <= now) continue;
59
+ const dueAt = expires - settings.leadTimeMinutes * 60_000;
60
+ if (!best || expires < Date.parse(best.expiresAt)) best = { grantedAt: credit.granted_at, expiresAt: credit.expires_at, dueAt };
61
+ }
62
+ return best;
63
+ }
64
+
65
+ export function creditStillPresent(credits: readonly ResetCredit[], plan: Pick<AutoRedeemPlan, "grantedAt" | "expiresAt">): boolean {
66
+ return credits.some(c => c.granted_at === plan.grantedAt && c.expires_at === plan.expiresAt);
67
+ }
68
+
69
+ interface JournalEntry {
70
+ accountKey: string;
71
+ grantedAt: string;
72
+ expiresAt: string;
73
+ redeemRequestId: string;
74
+ state: "dispatched" | "settled";
75
+ updatedAt: number;
76
+ }
77
+
78
+ interface Journal { version: 1; entries: JournalEntry[] }
79
+
80
+ export function journalPath(): string {
81
+ return join(getConfigDir(), "reset-credit-auto-redeem.json");
82
+ }
83
+
84
+ function readJournal(path: string): Journal {
85
+ if (!existsSync(path)) return { version: 1, entries: [] };
86
+ try {
87
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Journal;
88
+ return parsed && parsed.version === 1 && Array.isArray(parsed.entries) ? parsed : { version: 1, entries: [] };
89
+ } catch {
90
+ return { version: 1, entries: [] };
91
+ }
92
+ }
93
+
94
+ function writeJournal(path: string, journal: Journal): void {
95
+ // Keep only entries whose credit could still matter: settled ones older than a week are noise.
96
+ const cutoff = Date.now() - 7 * 24 * 60 * 60_000;
97
+ journal.entries = journal.entries.filter(e => e.state !== "settled" || e.updatedAt > cutoff);
98
+ atomicWriteFile(path, JSON.stringify(journal, null, 2));
99
+ }
100
+
101
+ export function hashAccountKey(accountId: string): string {
102
+ return createHash("sha256").update(accountId).digest("hex").slice(0, 12);
103
+ }
104
+
105
+ export interface AutoRedeemDeps {
106
+ accountId: string;
107
+ settings: () => ResetCreditAutoRedeemSettings;
108
+ /** Fresh upstream read of the credit list; throws on auth/transport failure. */
109
+ inspect: () => Promise<{ credits: ResetCredit[] }>;
110
+ /** Consume with a caller-owned idempotency key. Returns the upstream code. */
111
+ consume: (redeemRequestId: string) => Promise<{ code: string }>;
112
+ now?: () => number;
113
+ setTimer?: (fn: () => void, ms: number) => unknown;
114
+ clearTimer?: (handle: unknown) => void;
115
+ journalFile?: string;
116
+ log?: (line: string) => void;
117
+ /** Upper bound on one sleep so a laptop sleep or clock jump re-checks rather than trusting a stale plan. */
118
+ maxSleepMs?: number;
119
+ /** Interval to re-inspect when no credit is due yet (default 30 min). */
120
+ idleRecheckMs?: number;
121
+ }
122
+
123
+ export type AutoRedeemOutcome =
124
+ | { kind: "disabled" }
125
+ | { kind: "nothing-to-protect" }
126
+ | { kind: "scheduled"; dueAt: number }
127
+ | { kind: "skipped"; reason: "credit-gone" | "disabled-before-dispatch" }
128
+ | { kind: "dispatched"; code: string; redeemRequestId: string }
129
+ | { kind: "ambiguous"; redeemRequestId: string }
130
+ | { kind: "error"; message: string };
131
+
132
+ export interface ResetCreditAutoRedeemer {
133
+ /** Inspect, and either dispatch (if due) or schedule the next check. */
134
+ tick(): Promise<AutoRedeemOutcome>;
135
+ start(): void;
136
+ stop(): void;
137
+ }
138
+
139
+ export function createResetCreditAutoRedeemer(deps: AutoRedeemDeps): ResetCreditAutoRedeemer {
140
+ const now = deps.now ?? (() => Date.now());
141
+ const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
142
+ const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>));
143
+ const log = deps.log ?? ((line: string) => console.log(line));
144
+ const path = deps.journalFile ?? journalPath();
145
+ const accountKey = hashAccountKey(deps.accountId);
146
+ const maxSleepMs = deps.maxSleepMs ?? 15 * 60_000;
147
+ const idleRecheckMs = deps.idleRecheckMs ?? 30 * 60_000;
148
+ let handle: unknown = null;
149
+ let stopped = false;
150
+ let inFlight: Promise<AutoRedeemOutcome> | null = null;
151
+
152
+ const schedule = (ms: number): void => {
153
+ if (stopped) return;
154
+ if (handle !== null) clearTimer(handle);
155
+ handle = setTimer(() => { handle = null; void tick(); }, Math.max(0, Math.min(ms, maxSleepMs)));
156
+ };
157
+
158
+ const dispatch = async (plan: AutoRedeemPlan): Promise<AutoRedeemOutcome> => {
159
+ const journal = readJournal(path);
160
+ let entry = journal.entries.find(e => e.accountKey === accountKey && e.grantedAt === plan.grantedAt && e.expiresAt === plan.expiresAt);
161
+ if (entry?.state === "settled") return { kind: "skipped", reason: "credit-gone" };
162
+ if (!entry) {
163
+ entry = { accountKey, grantedAt: plan.grantedAt, expiresAt: plan.expiresAt, redeemRequestId: randomUUID(), state: "dispatched", updatedAt: now() };
164
+ journal.entries.push(entry);
165
+ // Journal BEFORE the network call: a crash after this line replays the same request id.
166
+ writeJournal(path, journal);
167
+ }
168
+ log(`[opencodex] reset-credit auto-redeem: dispatching for account ${accountKey} (credit expires ${plan.expiresAt})`);
169
+ let result: { code: string };
170
+ try {
171
+ result = await deps.consume(entry.redeemRequestId);
172
+ } catch (error) {
173
+ log(`[opencodex] reset-credit auto-redeem: consume uncertain for account ${accountKey}; will retry with the same request id`);
174
+ schedule(60_000);
175
+ return { kind: "ambiguous", redeemRequestId: entry.redeemRequestId };
176
+ }
177
+ entry.state = "settled";
178
+ entry.updatedAt = now();
179
+ writeJournal(path, journal);
180
+ log(`[opencodex] reset-credit auto-redeem: upstream answered ${result.code} for account ${accountKey}`);
181
+ schedule(idleRecheckMs);
182
+ return { kind: "dispatched", code: result.code, redeemRequestId: entry.redeemRequestId };
183
+ };
184
+
185
+ const tick = async (): Promise<AutoRedeemOutcome> => {
186
+ if (inFlight) return inFlight;
187
+ inFlight = (async () => {
188
+ const settings = deps.settings();
189
+ if (!settings.enabled) return { kind: "disabled" } as AutoRedeemOutcome;
190
+ let credits: ResetCredit[];
191
+ try {
192
+ ({ credits } = await deps.inspect());
193
+ } catch (error) {
194
+ schedule(idleRecheckMs);
195
+ return { kind: "error", message: error instanceof Error ? error.message : "inspect failed" } as AutoRedeemOutcome;
196
+ }
197
+ const plan = planAutoRedeem(now(), credits, settings);
198
+ if (!plan) { schedule(idleRecheckMs); return { kind: "nothing-to-protect" } as AutoRedeemOutcome; }
199
+ if (plan.dueAt > now()) { schedule(plan.dueAt - now()); return { kind: "scheduled", dueAt: plan.dueAt } as AutoRedeemOutcome; }
200
+ // Due: re-read right before spending. The plan above came from this same inspect, but
201
+ // the settings may have flipped and a manual consume may have raced; check both again.
202
+ if (!deps.settings().enabled) return { kind: "skipped", reason: "disabled-before-dispatch" } as AutoRedeemOutcome;
203
+ let fresh: ResetCredit[];
204
+ try { ({ credits: fresh } = await deps.inspect()); } catch (error) {
205
+ schedule(60_000);
206
+ return { kind: "error", message: error instanceof Error ? error.message : "inspect failed" } as AutoRedeemOutcome;
207
+ }
208
+ if (!creditStillPresent(fresh, plan)) { schedule(idleRecheckMs); return { kind: "skipped", reason: "credit-gone" } as AutoRedeemOutcome; }
209
+ return dispatch(plan);
210
+ })().finally(() => { inFlight = null; });
211
+ return inFlight;
212
+ };
213
+
214
+ return {
215
+ tick,
216
+ start() { stopped = false; void tick(); },
217
+ stop() { stopped = true; if (handle !== null) { clearTimer(handle); handle = null; } },
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Composition-root activation. Returns the redeemer only when the opt-in is on; the caller
223
+ * (src/server/index.ts) must not await this and must gate on `enabled` itself so a default
224
+ * install never constructs the timer.
225
+ */
226
+ export function activateResetCreditAutoRedeem(
227
+ config: OcxConfig,
228
+ wham: Pick<AutoRedeemDeps, "inspect" | "consume" | "accountId">,
229
+ ): ResetCreditAutoRedeemer {
230
+ const redeemer = createResetCreditAutoRedeemer({
231
+ ...wham,
232
+ settings: () => resolveResetCreditAutoRedeemSettings(config),
233
+ });
234
+ const unregister = registerOptionalShutdownHook("reset-credit-auto-redeem", () => { redeemer.stop(); unregister(); });
235
+ redeemer.start();
236
+ return redeemer;
237
+ }
@@ -1,4 +1,5 @@
1
1
  import type { OcxComboDefaultEffort, OcxComboTarget, OcxConfig } from "../types";
2
+ import { resolveEffortAtOrBelow } from "../reasoning-effort";
2
3
  import { resolveComboId } from "./types";
3
4
 
4
5
  const warnedUnsupportedDefaults = new Set<string>();
@@ -72,7 +73,18 @@ export function concreteComboRequestBody(
72
73
  if (!needsDefault) return clone;
73
74
  // Picker availability treats an unknown ladder as a wildcard, but runtime
74
75
  // injection stays fail-closed until this concrete target advertises support.
75
- if (!targetReasoningEfforts?.includes(defaultEffort)) {
76
+ //
77
+ // Support is not literal membership. The catalog advertises the combo's default
78
+ // through effectiveComboDefault, which keeps the highest supported rung at or
79
+ // below the request rather than dropping it. Testing membership here meant a
80
+ // combo configured for `max` against a target topping out at `high` sent no
81
+ // effort at all, so the provider default applied and the turn ran at `none`
82
+ // while the catalog still advertised `max` (#3108). Resolve the same way the
83
+ // catalog did.
84
+ const resolvedEffort = targetReasoningEfforts === undefined
85
+ ? undefined
86
+ : resolveEffortAtOrBelow(defaultEffort, targetReasoningEfforts);
87
+ if (!resolvedEffort) {
76
88
  const key = `${target.provider}/${target.model}:${defaultEffort}`;
77
89
  if (!warnedUnsupportedDefaults.has(key)) {
78
90
  warnedUnsupportedDefaults.add(key);
@@ -86,9 +98,9 @@ export function concreteComboRequestBody(
86
98
  return clone;
87
99
  }
88
100
  if (reasoning === undefined) {
89
- clone.reasoning = { effort: defaultEffort };
101
+ clone.reasoning = { effort: resolvedEffort };
90
102
  } else {
91
- clone.reasoning = { ...(reasoning as Record<string, unknown>), effort: defaultEffort };
103
+ clone.reasoning = { ...(reasoning as Record<string, unknown>), effort: resolvedEffort };
92
104
  }
93
105
  return clone;
94
106
  }
@@ -3,6 +3,7 @@ import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models";
3
3
  import type {
4
4
  OcxComboConfig,
5
5
  OcxComboDefaultEffort,
6
+ OcxComboReasoningEffortMode,
6
7
  OcxComboStrategy,
7
8
  OcxComboTarget,
8
9
  OcxConfig,
@@ -37,6 +38,8 @@ export interface NormalizedComboConfig {
37
38
  strategy: OcxComboStrategy;
38
39
  stickyLimit: number;
39
40
  defaultEffort: OcxComboDefaultEffort | null;
41
+ /** Picker-ladder derivation policy; `strict` preserves the legacy intersection rule. */
42
+ reasoningEffortMode: OcxComboReasoningEffortMode;
40
43
  /** Disable image input; `auto` preserves the intersection derived from all targets. */
41
44
  imageInput: "auto" | "disabled";
42
45
  /** Trimmed public alias, or null when the combo keeps the default `combo/<id>` slug. */
@@ -238,6 +241,14 @@ export function comboConfigIssues(
238
241
  if (body.imageInput !== undefined && body.imageInput !== "auto" && body.imageInput !== "disabled") {
239
242
  issues.push({ path: ["imageInput"], message: 'imageInput must be "auto" or "disabled"' });
240
243
  }
244
+ if (body.reasoningEffortMode !== undefined
245
+ && body.reasoningEffortMode !== "strict"
246
+ && body.reasoningEffortMode !== "adaptive") {
247
+ issues.push({
248
+ path: ["reasoningEffortMode"],
249
+ message: 'reasoningEffortMode must be "strict" or "adaptive"',
250
+ });
251
+ }
241
252
 
242
253
  if (body.alias !== undefined) {
243
254
  if (typeof body.alias !== "string") {
@@ -357,6 +368,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig
357
368
  strategy: raw.strategy ?? "failover",
358
369
  stickyLimit: raw.stickyLimit ?? 1,
359
370
  defaultEffort: raw.defaultEffort ?? null,
371
+ reasoningEffortMode: raw.reasoningEffortMode === "adaptive" ? "adaptive" : "strict",
360
372
  imageInput: raw.imageInput === "disabled" ? "disabled" : "auto",
361
373
  alias: alias || null,
362
374
  nativeAlias: raw.nativeAlias === true,
@@ -49,7 +49,21 @@ export function hardenConfigDir(): void {
49
49
  }
50
50
  }
51
51
 
52
- /** Test-only: settle optional config-directory hardening without exposing it to production callers. */
52
+ /**
53
+ * Settle the optional hardening flight for one config directory.
54
+ *
55
+ * The flight spawns `icacls.exe`, which holds the directory open until it exits. Windows file
56
+ * locking is mandatory, so anything that removes or renames that directory after a "clean"
57
+ * shutdown — a test fixture teardown, an uninstaller, a home move — gets EPERM/EBUSY unless the
58
+ * process that started the child also waits for it. `server.stop` calls this so the shutdown
59
+ * contract owns every child it started. No-op when nothing is in flight.
60
+ */
61
+ export async function flushConfigDirHardening(dir: string = getConfigDir()): Promise<void> {
62
+ const flight = configDirHardeningFlights.get(dir);
63
+ if (flight) await flight;
64
+ }
65
+
66
+ /** Test-only: settle every in-flight config-directory harden regardless of directory. */
53
67
  export async function flushConfigDirHardeningForTests(): Promise<void> {
54
68
  await Promise.all([...configDirHardeningFlights.values()]);
55
69
  }
@@ -1,5 +1,9 @@
1
1
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
2
2
  import { redactSecretString } from "../lib/redact";
3
+ import {
4
+ isValidModelDiscoveryModelId,
5
+ MODEL_DISCOVERY_MAX_MODELS,
6
+ } from "../providers/model-discovery-limits";
3
7
  import { modelRecordValue } from "../reasoning-effort";
4
8
  import {
5
9
  isWirePinnedModel,
@@ -20,6 +24,8 @@ const SENSITIVE_PROVIDER_HEADERS = new Set([
20
24
  "x-amz-security-token",
21
25
  ]);
22
26
  const REASONING_SUMMARY_DELIVERY_SET = new Set<string>(REASONING_SUMMARY_DELIVERY_VALUES);
27
+ const DISPLAY_NAME_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
28
+ const MAX_MODEL_DISPLAY_NAME_LENGTH = 128;
23
29
 
24
30
  /** Validate a provider destination without coupling DTO callers to config persistence. */
25
31
  export function providerBaseUrlConfigError(baseUrl: string): string | null {
@@ -124,6 +130,40 @@ export function booleanRecordConfigError(value: unknown, field: string): string
124
130
  return null;
125
131
  }
126
132
 
133
+ /** Validate display-only labels without changing the provider's model identity. */
134
+ export function modelDisplayNamesConfigError(
135
+ value: unknown,
136
+ field = "modelDisplayNames",
137
+ ): string | null {
138
+ if (value === undefined) return null;
139
+ if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
140
+ const prototype = Object.getPrototypeOf(value);
141
+ if (prototype !== Object.prototype && prototype !== null) {
142
+ return `${field} must be a plain object with own properties`;
143
+ }
144
+ const entries = Object.entries(value);
145
+ // One discovered model can own one label, so both maps share the same safe cap.
146
+ if (entries.length > MODEL_DISCOVERY_MAX_MODELS) {
147
+ return `${field} must contain at most ${MODEL_DISCOVERY_MAX_MODELS} entries`;
148
+ }
149
+ for (const [modelId, displayName] of entries) {
150
+ if (!isValidModelDiscoveryModelId(modelId)) return `${field} keys must be valid model ids`;
151
+ const safeModelId = JSON.stringify(redactSecretString(modelId));
152
+ if (typeof displayName !== "string") return `${field}.${safeModelId} must be a string`;
153
+ const trimmed = displayName.trim();
154
+ if (!trimmed) return `${field}.${safeModelId} must be nonblank`;
155
+ if (displayName !== trimmed) return `${field}.${safeModelId} must be trimmed`;
156
+ if (displayName.length > MAX_MODEL_DISPLAY_NAME_LENGTH) {
157
+ return `${field}.${safeModelId} must be at most ${MAX_MODEL_DISPLAY_NAME_LENGTH} characters`;
158
+ }
159
+ if (displayName.includes("/")) return `${field}.${safeModelId} must not contain /`;
160
+ if (DISPLAY_NAME_CONTROL_CHARS.test(displayName)) {
161
+ return `${field}.${safeModelId} must not contain control characters`;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
127
167
  /** Validate the management DTO boundary for the opt-in empty-tool-output annotation. */
128
168
  export function providerEmptyToolOutputConfigError(name: string, provider: unknown): string | null {
129
169
  const raw = provider as Record<string, unknown> | null | undefined;