@bitkyc08/opencodex 2.11.1 → 2.12.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 (175) hide show
  1. package/README.md +1 -1
  2. package/bin/ocx.mjs +4 -0
  3. package/gui/dist/assets/index-BNVYzdn0.css +1 -0
  4. package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/command-code.ts +94 -5
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-tool-schema.ts +80 -15
  10. package/src/adapters/google.ts +24 -7
  11. package/src/adapters/openai-chat.ts +233 -305
  12. package/src/adapters/openai-responses.ts +62 -0
  13. package/src/adapters/tool-catalog-nudge.ts +2 -11
  14. package/src/claude/outbound.ts +42 -14
  15. package/src/cli/claude.ts +61 -9
  16. package/src/cli/codex-shim-autorestore.ts +2 -0
  17. package/src/cli/codex-shim-readiness.ts +8 -1
  18. package/src/cli/config-command.ts +4 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +15 -0
  21. package/src/cli/index.ts +27 -11
  22. package/src/cli/lab.ts +367 -0
  23. package/src/cli/provider.ts +9 -1
  24. package/src/clients/config-export.ts +100 -3
  25. package/src/codex/app-server-processes.ts +11 -8
  26. package/src/codex/auth-api.ts +92 -28
  27. package/src/codex/auth-collision.ts +5 -3
  28. package/src/codex/catalog/metadata.ts +7 -3
  29. package/src/codex/catalog/provider-fetch.ts +191 -47
  30. package/src/codex/catalog.ts +2 -2
  31. package/src/codex/history-job.ts +83 -18
  32. package/src/codex/history-migration-guardian.ts +9 -16
  33. package/src/codex/history-provider.ts +178 -1
  34. package/src/codex/history-transition.ts +5 -5
  35. package/src/codex/history-worker.ts +22 -2
  36. package/src/codex/native-profile-store.ts +1 -1
  37. package/src/codex/plan.ts +15 -0
  38. package/src/codex/quota.ts +6 -6
  39. package/src/codex/routing.ts +3 -3
  40. package/src/codex/subagent-model-fallback.ts +246 -22
  41. package/src/codex/user-identity.ts +63 -12
  42. package/src/config.ts +265 -32
  43. package/src/images/loop.ts +3 -1
  44. package/src/images/plan.ts +23 -13
  45. package/src/integrations/omp-yaml-source.ts +225 -0
  46. package/src/integrations/ownership.ts +5 -5
  47. package/src/integrations/state.ts +45 -6
  48. package/src/integrations/writer.ts +38 -3
  49. package/src/lab/artifacts/sanitize.ts +586 -0
  50. package/src/lab/artifacts/secure-fs.ts +475 -0
  51. package/src/lab/artifacts/store.ts +310 -0
  52. package/src/lab/conformance/assertion.ts +325 -0
  53. package/src/lab/conformance/digest.ts +22 -0
  54. package/src/lab/conformance/executor.ts +741 -0
  55. package/src/lab/conformance/fixture-provider.ts +27 -0
  56. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  57. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  58. package/src/lab/conformance/harness-budget.ts +47 -0
  59. package/src/lab/conformance/index.ts +5 -0
  60. package/src/lab/conformance/jcs.ts +24 -0
  61. package/src/lab/conformance/json-pointer.ts +39 -0
  62. package/src/lab/conformance/manifest.ts +180 -0
  63. package/src/lab/conformance/mcp-stub.ts +179 -0
  64. package/src/lab/conformance/negative-controls.ts +160 -0
  65. package/src/lab/conformance/observation.ts +355 -0
  66. package/src/lab/conformance/runner.ts +57 -0
  67. package/src/lab/conformance/sse-normalize.ts +59 -0
  68. package/src/lab/conformance/suite-manifest.ts +78 -0
  69. package/src/lab/conformance/types.ts +191 -0
  70. package/src/lab/constants.ts +126 -0
  71. package/src/lab/digest.ts +64 -0
  72. package/src/lab/events/errors.ts +9 -0
  73. package/src/lab/events/limits.ts +117 -0
  74. package/src/lab/events/types.ts +228 -0
  75. package/src/lab/events/validate.ts +780 -0
  76. package/src/lab/index.ts +36 -0
  77. package/src/lab/ledger/artifact-refs.ts +127 -0
  78. package/src/lab/ledger/invalidation.ts +136 -0
  79. package/src/lab/ledger/purge.ts +241 -0
  80. package/src/lab/ledger/store.ts +263 -0
  81. package/src/lab/live/credential-lease.ts +53 -0
  82. package/src/lab/live/destination.ts +155 -0
  83. package/src/lab/live/executor.ts +311 -0
  84. package/src/lab/live/inert-tools.ts +56 -0
  85. package/src/lab/live/manifest.ts +85 -0
  86. package/src/lab/live/mcp-loopback.ts +57 -0
  87. package/src/lab/live/runner.ts +19 -0
  88. package/src/lab/live/sandbox.ts +61 -0
  89. package/src/lab/live/suite-manifest.ts +41 -0
  90. package/src/lab/live/transport.ts +116 -0
  91. package/src/lab/live/types.ts +197 -0
  92. package/src/lab/observe/from-conformance.ts +344 -0
  93. package/src/lab/observe/from-live.ts +103 -0
  94. package/src/lab/paths.ts +65 -0
  95. package/src/lab/projection/rebuild.ts +477 -0
  96. package/src/lab/projection/schema.ts +122 -0
  97. package/src/lab/projection/verdicts.ts +438 -0
  98. package/src/lab/projection/verification.ts +332 -0
  99. package/src/lab/query/catalog.ts +72 -0
  100. package/src/lab/query/connection.ts +107 -0
  101. package/src/lab/query/constants.ts +4 -0
  102. package/src/lab/query/cursor.ts +132 -0
  103. package/src/lab/query/dto-map.ts +277 -0
  104. package/src/lab/query/errors.ts +22 -0
  105. package/src/lab/query/index.ts +34 -0
  106. package/src/lab/query/queries.ts +444 -0
  107. package/src/lab/query/types.ts +266 -0
  108. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  109. package/src/lab/subject/installation-salt.ts +81 -0
  110. package/src/lab/subject/route-subject.ts +73 -0
  111. package/src/lib/bounded-body.ts +104 -0
  112. package/src/lib/lab-live-execution-authority.ts +13 -0
  113. package/src/lib/lab-live-host.ts +30 -0
  114. package/src/lib/lab-live-pinned-sender.ts +45 -0
  115. package/src/lib/pinned-http.ts +36 -7
  116. package/src/oauth/index.ts +20 -1
  117. package/src/oauth/local-token-detect.ts +11 -2
  118. package/src/oauth/login-cli.ts +38 -4
  119. package/src/providers/alibaba-region-migration.ts +1 -1
  120. package/src/providers/codex-capacity.ts +7 -4
  121. package/src/providers/command-code-efforts.ts +5 -2
  122. package/src/providers/derive.ts +105 -0
  123. package/src/providers/label.ts +1 -1
  124. package/src/providers/openai-tiers.ts +59 -3
  125. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  126. package/src/providers/quota.ts +67 -21
  127. package/src/providers/registry.ts +118 -5
  128. package/src/router.ts +15 -1
  129. package/src/routing/analytics.ts +8 -7
  130. package/src/routing/evaluator.ts +51 -5
  131. package/src/routing/profile.ts +10 -1
  132. package/src/routing/trace.ts +42 -6
  133. package/src/server/auth-cors.ts +15 -0
  134. package/src/server/background-lifecycle.ts +169 -0
  135. package/src/server/chat-completions.ts +18 -3
  136. package/src/server/claude-messages.ts +1 -0
  137. package/src/server/images.ts +78 -11
  138. package/src/server/index.ts +72 -18
  139. package/src/server/lifecycle.ts +6 -0
  140. package/src/server/live.ts +5 -1
  141. package/src/server/management/agent-settings-routes.ts +41 -24
  142. package/src/server/management/body.ts +6 -0
  143. package/src/server/management/config-routes.ts +51 -8
  144. package/src/server/management/lab-routes.ts +388 -0
  145. package/src/server/management/logs-usage-routes.ts +19 -1
  146. package/src/server/management/model-rows.ts +25 -11
  147. package/src/server/management/native-integration-routes.ts +3 -3
  148. package/src/server/management/provider-routes.ts +5 -0
  149. package/src/server/management/shared.ts +8 -1
  150. package/src/server/management/usage-summary-cache.ts +2 -0
  151. package/src/server/management/vision-sidecar-options.ts +125 -0
  152. package/src/server/management-api.ts +2 -0
  153. package/src/server/request-decompress.ts +106 -5
  154. package/src/server/responses/collaboration.ts +4 -1
  155. package/src/server/responses/core.ts +117 -31
  156. package/src/server/responses-json-events.ts +50 -12
  157. package/src/server/search.ts +23 -7
  158. package/src/server/ws-bridge.ts +3 -1
  159. package/src/service.ts +40 -20
  160. package/src/types.ts +74 -0
  161. package/src/update/job.ts +10 -2
  162. package/src/usage/cost.ts +0 -0
  163. package/src/usage/expected-prices.ts +9 -0
  164. package/src/usage/log.ts +9 -9
  165. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  166. package/src/usage/user-cost-overlays.ts +314 -0
  167. package/src/vision/describe.ts +2 -2
  168. package/src/vision/eligibility.ts +229 -0
  169. package/src/vision/index.ts +20 -2
  170. package/src/web-search/executor.ts +2 -2
  171. package/src/web-search/index.ts +4 -2
  172. package/src/web-search/loop.ts +3 -1
  173. package/src/web-search/parse.ts +72 -3
  174. package/gui/dist/assets/index-BA1dgo4q.css +0 -1
  175. package/gui/dist/assets/index-DDZpgzKk.js +0 -70
package/src/config.ts CHANGED
@@ -49,6 +49,10 @@ import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
49
49
  import { isLocalAttestationSecret } from "./lib/local-management-attestation";
50
50
  import { providerDestinationConfigError } from "./lib/destination-policy";
51
51
  import { redactSecretString } from "./lib/redact";
52
+ import {
53
+ resolveTrustedWindowsPowerShellExe,
54
+ resolveTrustedWindowsSystemDirectory,
55
+ } from "./lib/windows-elevation";
52
56
  import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
53
57
  import {
54
58
  isWirePinnedModel,
@@ -60,6 +64,7 @@ import {
60
64
  type OcxConfig,
61
65
  type OcxApiKeyEntry,
62
66
  type OcxProviderConfig,
67
+ type ProviderCostOverlay,
63
68
  } from "./types";
64
69
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
65
70
  import {
@@ -70,6 +75,13 @@ import {
70
75
  import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models";
71
76
  import { parseDesktopProfile } from "./claude/desktop-profile";
72
77
  import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
78
+ import {
79
+ COST4_RATE_KEYS,
80
+ isValidCost4Rate,
81
+ refreshUserCostOverlays,
82
+ withPreservedDiskOnlyProviders,
83
+ } from "./usage/user-cost-overlays";
84
+ import { MAX_COST4_RATE } from "./usage/expected-prices";
73
85
  import {
74
86
  DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES,
75
87
  MAX_APP_OWNED_MEMORY_BUDGET_MB,
@@ -613,6 +625,7 @@ const providerConfigSchema = z.object({
613
625
  apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
614
626
  responsesPath: z.string().min(1).optional(),
615
627
  statelessResponses: z.boolean().optional(),
628
+ requiresAdjacentResponsesToolResults: z.boolean().optional(),
616
629
  supportsServiceTier: z.boolean().optional(),
617
630
  preserveResponsesReasoningContent: z.boolean().optional(),
618
631
  allowPrivateNetwork: z.boolean().optional(),
@@ -699,6 +712,75 @@ export function providerHeadersConfigError(headers: unknown): string | null {
699
712
  return null;
700
713
  }
701
714
 
715
+ /**
716
+ * Validate `providers.<name>.modelCosts`: a plain object keyed by exact model
717
+ * id, each value a 4-tuple of non-negative finite USD-per-1M-token rates.
718
+ * Returns null when valid/absent, else a human-readable error.
719
+ */
720
+ export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null {
721
+ if (value === undefined) return null;
722
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
723
+ return `${field} must be a plain object keyed by model id`;
724
+ }
725
+ for (const [modelId, entry] of Object.entries(value)) {
726
+ if (!modelId.trim()) return `${field} keys must be nonblank model ids`;
727
+ // Redact secret-shaped model ids and JSON-escape control characters so a
728
+ // malformed write cannot echo a pasted key/secret back through the
729
+ // management API response.
730
+ const safeModelId = JSON.stringify(redactSecretString(modelId));
731
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
732
+ return `${field}.${safeModelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`;
733
+ }
734
+ const rates = entry as Record<string, unknown>;
735
+ for (const key of COST4_RATE_KEYS) {
736
+ const rate = rates[key];
737
+ if (!isValidCost4Rate(rate)) {
738
+ return `${field}.${safeModelId}.${key} must be a non-negative finite number at most ${MAX_COST4_RATE} (USD per 1M tokens)`;
739
+ }
740
+ }
741
+ // Reject unknown fields: a misplaced apiKey/apiKeyPool under a cost row
742
+ // would otherwise be persisted and echoed verbatim by display paths that
743
+ // mask only top-level provider secrets.
744
+ const extraKeys = Object.keys(rates)
745
+ .filter((key) => !(COST4_RATE_KEYS as readonly string[]).includes(key));
746
+ if (extraKeys.length > 0) {
747
+ return `${field}.${safeModelId} has unexpected fields ${JSON.stringify(extraKeys.map(redactSecretString).join(", "))} — only input, output, cacheRead, and cacheWrite are allowed (USD per 1M tokens)`;
748
+ }
749
+ }
750
+ return null;
751
+ }
752
+
753
+ /**
754
+ * Serialize `providers.<name>.modelCosts` for display: copy ONLY the four
755
+ * numeric rate fields per model and DROP secret-shaped model ids, so a pasted
756
+ * API key in a key position cannot be echoed back by CLI/DTO display paths.
757
+ * The result uses a null prototype so "__proto__" remains an own row.
758
+ */
759
+ export function sanitizeModelCostsForDisplay(costs: unknown): Record<string, ProviderCostOverlay> | undefined {
760
+ if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined;
761
+ const out = Object.create(null) as Record<string, ProviderCostOverlay>;
762
+ for (const [modelId, entry] of Object.entries(costs)) {
763
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
764
+ const rates = entry as Record<string, unknown>;
765
+ const input = rates.input;
766
+ const output = rates.output;
767
+ const cacheRead = rates.cacheRead;
768
+ const cacheWrite = rates.cacheWrite;
769
+ if (
770
+ isValidCost4Rate(input)
771
+ && isValidCost4Rate(output)
772
+ && isValidCost4Rate(cacheRead)
773
+ && isValidCost4Rate(cacheWrite)
774
+ ) {
775
+ // Secret-shaped ids are DROPPED rather than mapped to "[REDACTED]" so
776
+ // distinct rows cannot collapse into one placeholder key.
777
+ if (redactSecretString(modelId) !== modelId) continue;
778
+ out[modelId] = { input, output, cacheRead, cacheWrite };
779
+ }
780
+ }
781
+ return Object.keys(out).length > 0 ? out : undefined;
782
+ }
783
+
702
784
  /** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
703
785
  export function apiKeyTransportConfigError(
704
786
  provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
@@ -1070,6 +1152,12 @@ const configSchema = z.object({
1070
1152
  injectionModel: z.string().optional().catch(undefined),
1071
1153
  injectionEffort: z.string().optional().catch(undefined),
1072
1154
  syncCodexSubagentDefaults: z.boolean().optional().catch(undefined),
1155
+ // Per-primary-model fallback chains. Values must be non-empty string arrays;
1156
+ // malformed entries degrade to undefined rather than rejecting the whole config.
1157
+ subagentModelFallbackByModel: z.record(
1158
+ z.string(),
1159
+ z.array(z.string().trim().min(1)).min(1),
1160
+ ).optional().catch(undefined),
1073
1161
  codexShimAutoRestore: z.boolean().optional(),
1074
1162
  pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
1075
1163
  codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
@@ -1162,7 +1250,7 @@ const configSchema = z.object({
1162
1250
  if (!isValidProviderName(name)) {
1163
1251
  ctx.addIssue({
1164
1252
  code: "custom",
1165
- path: ["providers", name],
1253
+ path: ["providers", redactSecretString(name)],
1166
1254
  message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys or routing namespaces (policy)",
1167
1255
  });
1168
1256
  }
@@ -1173,7 +1261,7 @@ const configSchema = z.object({
1173
1261
  code: "custom",
1174
1262
  path: [
1175
1263
  "providers",
1176
- name,
1264
+ redactSecretString(name),
1177
1265
  openRouterRoutingError.startsWith("modelOpenRouterRouting")
1178
1266
  ? "modelOpenRouterRouting"
1179
1267
  : "openRouterRouting",
@@ -1184,7 +1272,7 @@ const configSchema = z.object({
1184
1272
  if (Object.hasOwn(provider, "virtualModels")) {
1185
1273
  ctx.addIssue({
1186
1274
  code: "custom",
1187
- path: ["providers", name, "virtualModels"],
1275
+ path: ["providers", redactSecretString(name), "virtualModels"],
1188
1276
  message: "virtualModels is registry-only and must not be persisted",
1189
1277
  });
1190
1278
  }
@@ -1192,7 +1280,7 @@ const configSchema = z.object({
1192
1280
  if (baseUrlError) {
1193
1281
  ctx.addIssue({
1194
1282
  code: "custom",
1195
- path: ["providers", name, "baseUrl"],
1283
+ path: ["providers", redactSecretString(name), "baseUrl"],
1196
1284
  message: baseUrlError,
1197
1285
  });
1198
1286
  } else {
@@ -1200,7 +1288,7 @@ const configSchema = z.object({
1200
1288
  if (destinationError) {
1201
1289
  ctx.addIssue({
1202
1290
  code: "custom",
1203
- path: ["providers", name, "baseUrl"],
1291
+ path: ["providers", redactSecretString(name), "baseUrl"],
1204
1292
  message: destinationError,
1205
1293
  });
1206
1294
  }
@@ -1209,7 +1297,7 @@ const configSchema = z.object({
1209
1297
  if (responsesPathError) {
1210
1298
  ctx.addIssue({
1211
1299
  code: "custom",
1212
- path: ["providers", name, "responsesPath"],
1300
+ path: ["providers", redactSecretString(name), "responsesPath"],
1213
1301
  message: responsesPathError,
1214
1302
  });
1215
1303
  }
@@ -1217,15 +1305,25 @@ const configSchema = z.object({
1217
1305
  if (headersError) {
1218
1306
  ctx.addIssue({
1219
1307
  code: "custom",
1220
- path: ["providers", name, "headers"],
1308
+ path: ["providers", redactSecretString(name), "headers"],
1221
1309
  message: headersError,
1222
1310
  });
1223
1311
  }
1312
+ const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts);
1313
+ if (modelCostsError) {
1314
+ ctx.addIssue({
1315
+ code: "custom",
1316
+ // The provider key is caller-controlled and can be token-shaped; redact it
1317
+ // before schemaDiagnosticsError serializes the path (ocx config validate/import).
1318
+ path: ["providers", redactSecretString(name), "modelCosts"],
1319
+ message: modelCostsError,
1320
+ });
1321
+ }
1224
1322
  const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig);
1225
1323
  if (apiKeyTransportError) {
1226
1324
  ctx.addIssue({
1227
1325
  code: "custom",
1228
- path: ["providers", name, "apiKeyTransport"],
1326
+ path: ["providers", redactSecretString(name), "apiKeyTransport"],
1229
1327
  message: apiKeyTransportError,
1230
1328
  });
1231
1329
  }
@@ -1238,7 +1336,7 @@ const configSchema = z.object({
1238
1336
  if (modelAdaptersError) {
1239
1337
  ctx.addIssue({
1240
1338
  code: "custom",
1241
- path: ["providers", name, "modelAdapters"],
1339
+ path: ["providers", redactSecretString(name), "modelAdapters"],
1242
1340
  message: modelAdaptersError,
1243
1341
  });
1244
1342
  }
@@ -1251,7 +1349,7 @@ const configSchema = z.object({
1251
1349
  if (preferHostedToolsError) {
1252
1350
  ctx.addIssue({
1253
1351
  code: "custom",
1254
- path: ["providers", name, "modelPreferHostedTools"],
1352
+ path: ["providers", redactSecretString(name), "modelPreferHostedTools"],
1255
1353
  message: preferHostedToolsError,
1256
1354
  });
1257
1355
  }
@@ -1262,7 +1360,7 @@ const configSchema = z.object({
1262
1360
  if (maxInputError) {
1263
1361
  ctx.addIssue({
1264
1362
  code: "custom",
1265
- path: ["providers", name, "modelMaxInputTokens"],
1363
+ path: ["providers", redactSecretString(name), "modelMaxInputTokens"],
1266
1364
  message: maxInputError,
1267
1365
  });
1268
1366
  }
@@ -1273,7 +1371,7 @@ const configSchema = z.object({
1273
1371
  if (reasoningSummariesError) {
1274
1372
  ctx.addIssue({
1275
1373
  code: "custom",
1276
- path: ["providers", name, "modelSupportsReasoningSummaries"],
1374
+ path: ["providers", redactSecretString(name), "modelSupportsReasoningSummaries"],
1277
1375
  message: reasoningSummariesError,
1278
1376
  });
1279
1377
  }
@@ -1284,7 +1382,7 @@ const configSchema = z.object({
1284
1382
  if (reasoningSummaryDeliveryError) {
1285
1383
  ctx.addIssue({
1286
1384
  code: "custom",
1287
- path: ["providers", name, "modelReasoningSummaryDelivery"],
1385
+ path: ["providers", redactSecretString(name), "modelReasoningSummaryDelivery"],
1288
1386
  message: reasoningSummaryDeliveryError,
1289
1387
  });
1290
1388
  }
@@ -1295,7 +1393,7 @@ const configSchema = z.object({
1295
1393
  if (defaultMaxOutputError) {
1296
1394
  ctx.addIssue({
1297
1395
  code: "custom",
1298
- path: ["providers", name, "defaultMaxOutputTokens"],
1396
+ path: ["providers", redactSecretString(name), "defaultMaxOutputTokens"],
1299
1397
  message: defaultMaxOutputError,
1300
1398
  });
1301
1399
  }
@@ -1306,7 +1404,7 @@ const configSchema = z.object({
1306
1404
  if (maxOutputError) {
1307
1405
  ctx.addIssue({
1308
1406
  code: "custom",
1309
- path: ["providers", name, "modelMaxOutputTokens"],
1407
+ path: ["providers", redactSecretString(name), "modelMaxOutputTokens"],
1310
1408
  message: maxOutputError,
1311
1409
  });
1312
1410
  }
@@ -1321,7 +1419,7 @@ const configSchema = z.object({
1321
1419
  if (!canonicalOpenAiShape) {
1322
1420
  ctx.addIssue({
1323
1421
  code: "custom",
1324
- path: ["providers", name, "codexAccountMode"],
1422
+ path: ["providers", redactSecretString(name), "codexAccountMode"],
1325
1423
  message: "codexAccountMode is valid only on the canonical built-in openai provider",
1326
1424
  });
1327
1425
  }
@@ -1539,6 +1637,54 @@ export function retryOn429PolicyConfigError(policy: unknown): string | null {
1539
1637
  return `retryOn429.${field} is invalid (${first.message})`;
1540
1638
  }
1541
1639
 
1640
+ /**
1641
+ * Load-time degradation for `providers.<name>.modelCosts`, mirroring
1642
+ * {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row
1643
+ * must not fail the whole config parse — that would back up config.json and
1644
+ * fall back to defaults, dropping otherwise valid providers and the default
1645
+ * route for a typo in a non-runtime display field. Invalid rows are dropped
1646
+ * with a warning; strict rejection stays at the management/write boundary
1647
+ * (providerManagementConfigError).
1648
+ */
1649
+ function sanitizeModelCostsForLoad(parsed: unknown): void {
1650
+ if (!parsed || typeof parsed !== "object") return;
1651
+ const root = parsed as Record<string, unknown>;
1652
+ const providers = root.providers;
1653
+ if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
1654
+ for (const [name, provider] of Object.entries(providers as Record<string, unknown>)) {
1655
+ // Runs before schema validation, so the provider name is untrusted: redact
1656
+ // secret-shaped names and JSON-escape control characters for the warning.
1657
+ const safeProviderName = JSON.stringify(redactSecretString(name));
1658
+ if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
1659
+ const p = provider as Record<string, unknown>;
1660
+ const costs = p.modelCosts;
1661
+ if (costs === undefined) continue;
1662
+ if (!costs || typeof costs !== "object" || Array.isArray(costs)) {
1663
+ delete p.modelCosts;
1664
+ console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts (${typeof costs}) is invalid — ignoring the overlay`);
1665
+ continue;
1666
+ }
1667
+ const costsRecord = costs as Record<string, unknown>;
1668
+ const hadEntries = Object.keys(costsRecord).length > 0;
1669
+ let kept = 0;
1670
+ for (const [modelId, entry] of Object.entries(costsRecord)) {
1671
+ // Reuse the shared per-row shape contract so the load-time sanitizer
1672
+ // cannot drift from the schema and the write boundary.
1673
+ if (providerModelCostsConfigError({ [modelId]: entry }) === null) {
1674
+ kept++;
1675
+ continue;
1676
+ }
1677
+ delete costsRecord[modelId];
1678
+ // Redact the model id: a hand-edit can place a secret in a key name.
1679
+ console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts.${JSON.stringify(redactSecretString(modelId))} is invalid — ignoring the row`);
1680
+ }
1681
+ if (hadEntries && kept === 0) {
1682
+ delete p.modelCosts;
1683
+ console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts has no valid rows left — removing the overlay`);
1684
+ }
1685
+ }
1686
+ }
1687
+
1542
1688
  /**
1543
1689
  * Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind
1544
1690
  * falls back to loopback, which is the safe direction but not what the file asked for —
@@ -1793,6 +1939,13 @@ function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig)
1793
1939
  }
1794
1940
  }
1795
1941
 
1942
+ /**
1943
+ * Load and validate config.json into an OcxConfig. Missing files reset to
1944
+ * defaults and clear stale overlays. Broken existing files also fall back to
1945
+ * default routing (after backup), but keep the last-good cost-overlay registry
1946
+ * until a valid config or a genuinely missing file is observed. A partially-
1947
+ * invalid config is merged with defaults so providers and pool accounts survive.
1948
+ */
1796
1949
  export function loadConfig(): OcxConfig {
1797
1950
  const dir = getConfigDir();
1798
1951
  const configPath = getConfigPath();
@@ -1800,12 +1953,13 @@ export function loadConfig(): OcxConfig {
1800
1953
  hardenExistingSecret(configPath);
1801
1954
  hardenExistingSecret(join(dir, "auth.json"));
1802
1955
  if (!existsSync(configPath)) {
1803
- return getDefaultConfig();
1956
+ return withRefreshedCostOverlays(getDefaultConfig());
1804
1957
  }
1805
1958
  try {
1806
1959
  const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
1807
1960
  const parsed = JSON.parse(raw);
1808
1961
  sanitizeRetryOn429ForLoad(parsed);
1962
+ sanitizeModelCostsForLoad(parsed);
1809
1963
  const result = configSchema.safeParse(parsed);
1810
1964
  if (result.success) {
1811
1965
  const config = normalizeApiKeyIds(result.data as OcxConfig);
@@ -1817,7 +1971,7 @@ export function loadConfig(): OcxConfig {
1817
1971
  warnDegradedNativeSubagentConfig(parsed, config);
1818
1972
  warnDegradedCodexAccountPicker(parsed);
1819
1973
  warnDegradedUpstreamHostCircuitThreshold(parsed);
1820
- return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
1974
+ return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
1821
1975
  }
1822
1976
  // Schema validation failed — merge defaults into the raw object instead of
1823
1977
  // discarding it entirely, so pool accounts and providers survive a missing
@@ -1839,7 +1993,7 @@ export function loadConfig(): OcxConfig {
1839
1993
  warnDegradedNativeSubagentConfig(parsed, config);
1840
1994
  warnDegradedCodexAccountPicker(parsed);
1841
1995
  warnDegradedUpstreamHostCircuitThreshold(parsed);
1842
- return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
1996
+ return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
1843
1997
  }
1844
1998
  // Merge couldn't fix it — truly broken config
1845
1999
  warnAndBackupInvalidConfig(configPath, result.error);
@@ -1850,6 +2004,12 @@ export function loadConfig(): OcxConfig {
1850
2004
  }
1851
2005
  }
1852
2006
 
2007
+ /** Refresh the user cost-overlay registry from `config` and return it unchanged. */
2008
+ function withRefreshedCostOverlays(config: OcxConfig): OcxConfig {
2009
+ refreshUserCostOverlays(config);
2010
+ return config;
2011
+ }
2012
+
1853
2013
  export type ConfigDiagnostics = {
1854
2014
  config: OcxConfig;
1855
2015
  source: "default" | "file" | "fallback";
@@ -2085,6 +2245,7 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
2085
2245
  // schema and send the caller a default-config fallback (the config command could then
2086
2246
  // persist that fallback over the user's providers/keys).
2087
2247
  sanitizeRetryOn429ForLoad(parsed);
2248
+ sanitizeModelCostsForLoad(parsed);
2088
2249
  const result = configSchema.safeParse(parsed);
2089
2250
  if (result.success) {
2090
2251
  return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed);
@@ -2368,18 +2529,41 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync
2368
2529
  }
2369
2530
  };
2370
2531
 
2532
+ /**
2533
+ * Atomic config.json write WITHOUT the mutation lock; callers must hold
2534
+ * `withConfigMutationLockSync`. Returns true when bytes changed. Refreshes the
2535
+ * cost-overlay registry from the persisted config so runtime estimates follow
2536
+ * every save path.
2537
+ */
2371
2538
  function persistConfigUnlocked(config: OcxConfig): boolean {
2372
2539
  const configPath = getConfigPath();
2373
- const bytes = JSON.stringify(config, null, 2) + "\n";
2540
+ // External editors can add provider rows the live config deliberately does
2541
+ // not route with yet; merge them at the serialization boundary so an
2542
+ // unrelated in-process save cannot erase the provider or its overlay.
2543
+ const persisted = withPreservedDiskOnlyProviders(config);
2544
+ const bytes = JSON.stringify(persisted, null, 2) + "\n";
2545
+ let unchanged = false;
2374
2546
  try {
2375
- if (readFileSync(configPath, "utf8") === bytes) return false;
2547
+ unchanged = readFileSync(configPath, "utf8") === bytes;
2376
2548
  } catch (error) {
2377
2549
  if (!isMissingPathError(error)) throw error;
2378
2550
  }
2551
+ // Keep the runtime overlay registry in sync with EVERY persist path,
2552
+ // including byte-identical saves: a cooperating CLI process may have written
2553
+ // the same bytes (e.g. before a proxy notification), and Logs/Usage must
2554
+ // adopt the overlay without waiting for a changed save or restart.
2555
+ if (unchanged) {
2556
+ refreshUserCostOverlays(persisted);
2557
+ return false;
2558
+ }
2379
2559
  atomicWriteFile(configPath, bytes);
2560
+ // For changed saves, refresh only AFTER the write succeeded so a failed
2561
+ // write cannot leave estimates reflecting configuration never persisted.
2562
+ refreshUserCostOverlays(persisted);
2380
2563
  return true;
2381
2564
  }
2382
2565
 
2566
+ /** Persist `config` to config.json under the config-mutation lock. */
2383
2567
  export function saveConfig(config: OcxConfig): void {
2384
2568
  // Keep the real-home assertion ahead of even lock-directory preparation.
2385
2569
  assertNotRealHomeUnderTest(getConfigDir());
@@ -2654,6 +2838,10 @@ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline
2654
2838
  else config.claudeCode = structuredClone(persisted.claudeCode);
2655
2839
  claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
2656
2840
  }
2841
+ // The reconciliation may have adopted a providers.<name>.modelCosts edit made
2842
+ // by a cooperating process while the OAuth login was pending; keep the overlay
2843
+ // registry (and the usage-cache overlay version) in sync with the live config.
2844
+ refreshUserCostOverlays(config);
2657
2845
  }
2658
2846
 
2659
2847
  /** The literal file, with no schema merge or default injection. */
@@ -3063,14 +3251,51 @@ export function verifyPidIdentity(candidatePid: number): number | null {
3063
3251
  return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null;
3064
3252
  }
3065
3253
 
3254
+ type ProcessCommandLineExec = (
3255
+ executable: string,
3256
+ args: string[],
3257
+ options: {
3258
+ encoding: BufferEncoding;
3259
+ stdio: ["ignore", "pipe", "ignore"];
3260
+ timeout: number;
3261
+ windowsHide: boolean;
3262
+ },
3263
+ ) => string;
3264
+
3265
+ const defaultProcessCommandLineExec: ProcessCommandLineExec = (executable, args, options) =>
3266
+ execFileSync(executable, args, options);
3267
+ let processCommandLineExec = defaultProcessCommandLineExec;
3268
+ let processCommandLinePlatformForTests: NodeJS.Platform | null = null;
3269
+
3270
+ /** Test-only seam for verifying the exact system executable selected by pid identity probes. */
3271
+ export function setProcessCommandLineExecForTests(next: ProcessCommandLineExec | null): void {
3272
+ processCommandLineExec = next ?? defaultProcessCommandLineExec;
3273
+ }
3274
+
3275
+ /** Test-only seam so cross-platform tests do not mutate process.platform. */
3276
+ export function setProcessCommandLinePlatformForTests(next: NodeJS.Platform | null): void {
3277
+ processCommandLinePlatformForTests = next;
3278
+ }
3279
+
3066
3280
  function readProcessCommandLine(pid: number): string | undefined {
3281
+ if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
3282
+ const platform = processCommandLinePlatformForTests ?? process.platform;
3067
3283
  try {
3068
- if (process.platform === "win32") {
3284
+ if (platform === "linux") {
3285
+ try {
3286
+ const output = readFileSync(`/proc/${pid}/cmdline`, "utf-8");
3287
+ const value = output.replace(/\0/g, " ").trim();
3288
+ if (value) return value;
3289
+ } catch {
3290
+ /* procfs unavailable — use the fixed ps fallback below */
3291
+ }
3292
+ }
3293
+ if (platform === "win32") {
3069
3294
  // Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash.
3070
3295
  // Fall back to PowerShell when WMIC is absent (newer Windows images).
3071
- const wmic = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\wbem\\WMIC.exe`;
3296
+ const wmic = join(resolveTrustedWindowsSystemDirectory(), "wbem", "WMIC.exe");
3072
3297
  try {
3073
- const output = execFileSync(wmic, [
3298
+ const output = processCommandLineExec(wmic, [
3074
3299
  "process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE",
3075
3300
  ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
3076
3301
  const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, ""));
@@ -3079,7 +3304,7 @@ function readProcessCommandLine(pid: number): string | undefined {
3079
3304
  } catch {
3080
3305
  /* WMIC missing or failed — fall through */
3081
3306
  }
3082
- const output = execFileSync("powershell.exe", [
3307
+ const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [
3083
3308
  "-NoProfile",
3084
3309
  "-NoLogo",
3085
3310
  "-NonInteractive",
@@ -3090,13 +3315,21 @@ function readProcessCommandLine(pid: number): string | undefined {
3090
3315
  ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
3091
3316
  return output.trim() || undefined;
3092
3317
  }
3093
- const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
3094
- encoding: "utf-8",
3095
- stdio: ["ignore", "pipe", "ignore"],
3096
- timeout: 1000,
3097
- windowsHide: true,
3098
- });
3099
- return output.trim() || undefined;
3318
+ for (const ps of ["/bin/ps", "/usr/bin/ps"]) {
3319
+ try {
3320
+ const output = processCommandLineExec(ps, ["-p", String(pid), "-o", "command="], {
3321
+ encoding: "utf-8",
3322
+ stdio: ["ignore", "pipe", "ignore"],
3323
+ timeout: 1000,
3324
+ windowsHide: true,
3325
+ });
3326
+ const value = output.trim();
3327
+ if (value) return value;
3328
+ } catch {
3329
+ /* try the other fixed system path */
3330
+ }
3331
+ }
3332
+ return undefined;
3100
3333
  } catch {
3101
3334
  return undefined;
3102
3335
  }
@@ -15,7 +15,7 @@ import { existsSync } from "node:fs";
15
15
  import { pathToFileURL } from "node:url";
16
16
  import { createAdapterEventQueue } from "../adapters/run-turn-queue";
17
17
  import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxRequestOptions, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
18
- import { namespacedToolName } from "../types";
18
+ import { namespacedToolName, toolChoiceToolPredicate } from "../types";
19
19
  import type { AttemptRecoveryKind } from "../usage/log";
20
20
  import { bridgeToResponsesSSE } from "../bridge";
21
21
  import { clearableDeadline, idleDeadline } from "../lib/abort";
@@ -662,7 +662,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
662
662
  const toolNsMap = new Map<string, { namespace: string; name: string }>();
663
663
  const freeform = new Set<string>();
664
664
  const toolSearch = new Set<string>();
665
+ const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
665
666
  for (const t of parsed.context.tools ?? []) {
667
+ if (!toolAllowed(t)) continue;
666
668
  if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
667
669
  if (t.freeform) freeform.add(t.name);
668
670
  if (t.toolSearch) toolSearch.add(t.name);
@@ -1,4 +1,5 @@
1
1
  import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
2
+ import { toolChoiceToolPredicate } from "../types";
2
3
  import type { ImageBridgePlan, VideoBridgePlan } from "./types";
3
4
  import { resolveEnvValue } from "../config";
4
5
  import { getProviderRegistryEntry } from "../providers/registry";
@@ -46,6 +47,12 @@ export async function planImageBridge(
46
47
  ): Promise<ImageBridgePlan | undefined> {
47
48
  if (config.images?.bridgeEnabled !== true) return undefined;
48
49
  if (!parsed._imageGeneration) return undefined;
50
+ const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
51
+ const toolNames = new Set(
52
+ [...parsed._imageGeneration.toolNames, IMAGE_GEN_TOOL_NAME]
53
+ .filter(name => toolAllowed({ name })),
54
+ );
55
+ if (toolNames.size === 0) return undefined;
49
56
  // Don't intercept for OpenAI native passthrough
50
57
  const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })();
51
58
  if (host === "api.openai.com") return undefined;
@@ -57,9 +64,7 @@ export async function planImageBridge(
57
64
  const registryEntry = getProviderRegistryEntry("xai");
58
65
  const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, "");
59
66
  // The synthetic tool injected into the conversation is named IMAGE_GEN_TOOL_NAME,
60
- // which is what the model will actually call. Merge it with any original hosted tool names.
61
- const toolNames = new Set(parsed._imageGeneration.toolNames);
62
- toolNames.add(IMAGE_GEN_TOOL_NAME);
67
+ // which is what the model will actually call. toolNames also retains authorized hosted aliases.
63
68
  const original = parsed._imageGeneration.originalTool;
64
69
  const hostedSize = typeof original?.size === "string" ? original.size : undefined;
65
70
  const hostedQuality = typeof original?.quality === "string" ? original.quality : undefined;
@@ -95,16 +100,6 @@ export async function planVideoBridge(
95
100
  routedProvider: OcxProviderConfig,
96
101
  ): Promise<VideoBridgePlan | undefined> {
97
102
  if (config.images?.videoBridgeEnabled !== true) return undefined;
98
- // Don't intercept for OpenAI native passthrough
99
- const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })();
100
- if (host === "api.openai.com") return undefined;
101
- const found = findXaiProvider(config);
102
- if (!found) return undefined;
103
- const token = resolveXaiImageApiKey(found.provider);
104
- if (!token) return undefined;
105
- // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override.
106
- const registryEntry = getProviderRegistryEntry("xai");
107
- const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, "");
108
103
  const toolNames = new Set<string>();
109
104
  toolNames.add(VIDEO_GEN_TOOL_NAME);
110
105
  // Collect any existing function tools whose name matches a video_gen alias
@@ -118,6 +113,21 @@ export async function planVideoBridge(
118
113
  toolNames.add(fnName);
119
114
  }
120
115
  }
116
+ const toolAllowed = toolChoiceToolPredicate(parsed.options?.toolChoice);
117
+ for (const name of toolNames) {
118
+ if (!toolAllowed({ name })) toolNames.delete(name);
119
+ }
120
+ if (toolNames.size === 0) return undefined;
121
+ // Don't intercept for OpenAI native passthrough
122
+ const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })();
123
+ if (host === "api.openai.com") return undefined;
124
+ const found = findXaiProvider(config);
125
+ if (!found) return undefined;
126
+ const token = resolveXaiImageApiKey(found.provider);
127
+ if (!token) return undefined;
128
+ // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override.
129
+ const registryEntry = getProviderRegistryEntry("xai");
130
+ const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, "");
121
131
  const timeoutMs = clampImageTimeoutMs(config.images?.videoTimeoutMs);
122
132
  const keepRaw = config.images?.artifactsKeepCount;
123
133
  const artifactsKeepCount =