@bitkyc08/opencodex 2.55.0 → 2.57.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 (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The one place that knows how this proxy refuses a turn on its own workflow budget.
3
+ *
4
+ * It is a module rather than two inline blocks because the two call sites -- the HTTP admission
5
+ * check in `src/server/index.ts` and the pre-dispatch ceiling check in
6
+ * `src/server/responses/core.ts` -- had drifted into saying different things about the same
7
+ * refusal, and because the non-obvious part below has to be stated once and not twice.
8
+ */
9
+ import { formatErrorResponse } from "../bridge";
10
+ import {
11
+ addFinalRequestLog,
12
+ markLocalRequestLogRefusal,
13
+ type RequestLogContext,
14
+ } from "./request-log";
15
+ import {
16
+ WORKFLOW_LOCAL_REFUSAL_HEADER,
17
+ workflowDenialSummary,
18
+ recordWorkflowRefusalEvent,
19
+ type WorkflowDenial,
20
+ } from "../lib/workflow-budget";
21
+
22
+ /**
23
+ * What a caller needs to hand over for the refusal to become a row on `/api/logs`.
24
+ *
25
+ * The HTTP admission check refuses before the body is parsed, so its `logCtx` still carries the
26
+ * `unknown` model and provider the caller seeded it with. That is the honest record -- this
27
+ * request genuinely never resolved either -- and it is the same placeholder the native
28
+ * passthrough path already writes. Skipping the row entirely was the worse option: an operator
29
+ * reading the logs saw no trace at all of a request the proxy had refused.
30
+ */
31
+ export interface WorkflowRefusalLog {
32
+ readonly requestId: string;
33
+ readonly start: number;
34
+ readonly logCtx: RequestLogContext;
35
+ }
36
+
37
+ /**
38
+ * Build the 429 for a refusal this proxy made itself.
39
+ *
40
+ * The status and type arguments below do not reach the client: `classifyError` rewrites every
41
+ * 429 to `rate_limit_error` / `rate_limit_exceeded`, so the body is shaped exactly like a
42
+ * provider rate limit. That is a deliberate wire contract -- changing it would change how every
43
+ * client retries -- which leaves two places to carry the truth. The message names the ceiling
44
+ * that fired and says no provider was contacted, and the header carries the machine-readable
45
+ * name. Nothing upstream sets that header, so its presence is conclusive.
46
+ *
47
+ * The row is where an operator actually looks, so it gets the same treatment #4639 established:
48
+ * `terminalSource: "synthetic"`, a local reason, and an error code naming the ceiling. Pass
49
+ * `logCtx` when the caller is inside a turn that will write its own row, or `refusalLog` when
50
+ * the refusal happens before any row exists and this is the only chance to write one.
51
+ */
52
+ export function workflowRefusalResponse(
53
+ reason: WorkflowDenial,
54
+ logCtx?: RequestLogContext,
55
+ refusalLog?: WorkflowRefusalLog,
56
+ rootId?: string,
57
+ ): Response {
58
+ const summary = workflowDenialSummary(reason);
59
+ // Only a caller that decided the refusal ITSELF passes a root id. admitWorkflowTurn already
60
+ // records its own denials, so passing one there would double-count them.
61
+ if (rootId) recordWorkflowRefusalEvent(rootId, reason);
62
+ const recordOn = logCtx ?? refusalLog?.logCtx;
63
+ if (recordOn) {
64
+ markLocalRequestLogRefusal(recordOn, summary.code);
65
+ // A locally assigned code wins in addFinalRequestLog, so this is what names the ceiling in
66
+ // the logs column rather than the generic rate-limit classification a 429 would get.
67
+ recordOn.errorCode = summary.code;
68
+ }
69
+ if (refusalLog) {
70
+ addFinalRequestLog(refusalLog.requestId, refusalLog.start, refusalLog.logCtx, 429, {
71
+ closeReason: "terminal",
72
+ });
73
+ }
74
+ const refusal = formatErrorResponse(
75
+ 429,
76
+ reason === "workflow-sends-exhausted" ? "workflow_budget_exhausted" : "queue_capacity_exceeded",
77
+ summary.message,
78
+ );
79
+ refusal.headers.set(WORKFLOW_LOCAL_REFUSAL_HEADER, summary.code);
80
+ // Without this a browser dashboard cannot read the header at all: the data plane never sets
81
+ // Access-Control-Expose-Headers, so a cross-origin reader sees only the CORS-safelisted ones.
82
+ refusal.headers.set("Access-Control-Expose-Headers", WORKFLOW_LOCAL_REFUSAL_HEADER);
83
+ return refusal;
84
+ }
@@ -1,4 +1,5 @@
1
- import { chmodSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, lstatSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { win32 } from "node:path";
3
4
  import { winswXmlPath } from "../lib/winsw";
4
5
  import { hardenSecretPath } from "../lib/windows-secret-acl";
@@ -9,7 +10,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmdirSync, unlinkSync } from "node:
9
10
  import { tmpdir } from "node:os";
10
11
  import { dirname, join } from "node:path";
11
12
  import { getConfigDir } from "../config";
12
- import { runWindowsElevatedScheduledTaskRegistration, WindowsSchtasksError } from "../lib/windows-elevation";
13
+ import { OCX_ELEVATED_STAGING_UNREADABLE, runWindowsElevatedScheduledTaskRegistration, WindowsSchtasksError, type StagedWindowsTaskXml } from "../lib/windows-elevation";
13
14
  import { defaultWinswEntry, installWinswService, statusWinswRaw, uninstallWinswService, WINSW_SERVICE_ID, type WinswStatus } from "../lib/winsw";
14
15
  import { forgetEphemeralSecretDir, forgetEphemeralSecretPath, hardenSecretDir } from "../lib/windows-secret-acl";
15
16
  import { recordOwnedConfigPath } from "../lib/config-ownership";
@@ -165,6 +166,197 @@ function cleanupWindowsSchedulerStage(
165
166
  if (cleanupError) throw cleanupError;
166
167
  }
167
168
 
169
+ /** A staged payload set for one elevated registration, plus the way to remove it. */
170
+ export interface StagedElevatedSchedulerRegistration {
171
+ readonly xml: StagedWindowsTaskXml;
172
+ readonly expectedExisting?: StagedWindowsTaskXml;
173
+ /** Remove every staged artifact. Idempotent, so a second call after success is a no-op. */
174
+ cleanup(): void;
175
+ }
176
+
177
+ export interface ElevatedSchedulerStagingDeps {
178
+ createStageDir?: () => string;
179
+ hardenDir?: (path: string) => void;
180
+ writePayload?: (path: string, bytes: Buffer) => void;
181
+ hardenPath?: (path: string) => void;
182
+ inspect?: (path: string) => { isSymbolicLink(): boolean; isFile(): boolean; isDirectory(): boolean };
183
+ removeStageDir?: (path: string) => void;
184
+ }
185
+
186
+ /**
187
+ * Stage the captured definitions an elevated registration needs, as files rather than
188
+ * as command-line payloads (#4692).
189
+ *
190
+ * A file that an administrator process will read is itself a privilege-escalation
191
+ * surface, so three properties have to hold together and none of them is sufficient
192
+ * alone:
193
+ *
194
+ * - **Access.** The directory is created fresh by `mkdtemp`, then ACL-hardened before
195
+ * anything is written into it, so another local account cannot read or replace the
196
+ * payload while the UAC prompt is open. Hardening the directory first is what makes
197
+ * the file private from the moment it exists.
198
+ * - **No reparse point.** Each artifact is inspected with `lstat` and rejected unless it
199
+ * is what it claims to be. `wx` already refuses to create over an existing name, which
200
+ * is the atomic step here — there is no replace path to race, because every path is
201
+ * inside a directory that did not exist a moment ago. The explicit check is what keeps
202
+ * that guarantee from depending on a reading of `O_EXCL` semantics.
203
+ * - **Tamper evidence.** The digest is taken over the exact bytes written, and the
204
+ * elevated script recomputes it over the bytes it reads. An ACL cannot cover this:
205
+ * a process running as the same user has the same SID and can rewrite the file, so
206
+ * the digest is the only thing that makes such a swap fail closed rather than
207
+ * silently register a different task definition.
208
+ *
209
+ * Payloads are UTF-16LE with no BOM, and the elevated process decodes them straight into
210
+ * `Register-ScheduledTask`. What is hashed is therefore exactly what is registered, with
211
+ * no trimming step in between that the two sides could disagree about.
212
+ */
213
+ export function stageElevatedSchedulerRegistration(
214
+ xml: string,
215
+ expectedExistingXml?: string,
216
+ deps: ElevatedSchedulerStagingDeps = {},
217
+ ): StagedElevatedSchedulerRegistration {
218
+ const createStageDir = deps.createStageDir
219
+ ?? (() => mkdtempSync(join(tmpdir(), WINDOWS_SCHEDULER_STAGE_PREFIX)));
220
+ const hardenDir = deps.hardenDir ?? ((path: string) => { hardenSecretDir(path, { required: true }); });
221
+ const writePayload = deps.writePayload ?? ((path: string, bytes: Buffer) => {
222
+ writeFileSync(path, bytes, { flag: "wx", mode: 0o600 });
223
+ });
224
+ const hardenPath = deps.hardenPath ?? ((path: string) => { hardenSecretPath(path, { required: true }); });
225
+ const inspect = deps.inspect ?? ((path: string) => lstatSync(path));
226
+ const removeStageDir = deps.removeStageDir ?? ((path: string) => { rmdirSync(path); });
227
+
228
+ const stageDir = createStageDir();
229
+ const files: string[] = [];
230
+ const cleanup = (): void => {
231
+ let failure: unknown;
232
+ for (const file of files.splice(0)) {
233
+ try {
234
+ unlinkSync(file);
235
+ forgetEphemeralSecretPath(file);
236
+ } catch (error) {
237
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") forgetEphemeralSecretPath(file);
238
+ else if (failure === undefined) failure = error;
239
+ }
240
+ }
241
+ try {
242
+ removeStageDir(stageDir);
243
+ forgetEphemeralSecretDir(stageDir);
244
+ } catch (error) {
245
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") forgetEphemeralSecretDir(stageDir);
246
+ else if (failure) throw new AggregateError([failure, error], "Elevated Task Scheduler staging cleanup failed.");
247
+ else failure = error;
248
+ }
249
+ if (failure) throw failure;
250
+ };
251
+
252
+ try {
253
+ try { chmodSync(stageDir, 0o700); } catch { /* required Windows ACL is authoritative */ }
254
+ const dirStats = inspect(stageDir);
255
+ if (dirStats.isSymbolicLink() || !dirStats.isDirectory()) {
256
+ throw new Error(`Refusing to stage an elevated Task Scheduler payload under a redirected path: ${stageDir}`);
257
+ }
258
+ hardenDir(stageDir);
259
+ const stage = (name: string, value: string): StagedWindowsTaskXml => {
260
+ const path = join(stageDir, name);
261
+ const bytes = Buffer.from(value, "utf16le");
262
+ writePayload(path, bytes);
263
+ files.push(path);
264
+ const stats = inspect(path);
265
+ if (stats.isSymbolicLink() || !stats.isFile()) {
266
+ throw new Error(`Refusing to stage an elevated Task Scheduler payload through a redirected path: ${path}`);
267
+ }
268
+ hardenPath(path);
269
+ return { path, sha256: createHash("sha256").update(bytes).digest("hex") };
270
+ };
271
+ return {
272
+ xml: stage("register.xml", xml),
273
+ ...(expectedExistingXml === undefined
274
+ ? {}
275
+ : { expectedExisting: stage("expected.xml", expectedExistingXml) }),
276
+ cleanup,
277
+ };
278
+ } catch (error) {
279
+ try {
280
+ cleanup();
281
+ } catch (cleanupError) {
282
+ throw new AggregateError(
283
+ [error, cleanupError],
284
+ "Elevated Task Scheduler staging failed and could not be cleaned up.",
285
+ );
286
+ }
287
+ throw error;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Turn an elevated registration exit code into something an operator can act on.
293
+ *
294
+ * The elevated process runs hidden, so nothing it writes survives; only the exit code
295
+ * crosses back. That makes an unexplained code the whole user-facing error, which is
296
+ * exactly what made the ENAMETOOLONG in #4692 expensive to diagnose. Staging introduces
297
+ * one new failure of its own — the payload is readable only by the account that created
298
+ * it, so an elevation answered with a different administrator's credentials cannot open
299
+ * it — and that one gets named along with its remedy rather than surfacing as a number.
300
+ */
301
+ export function describeElevatedRegistrationFailure(
302
+ failureLabel: string,
303
+ exitCode: number,
304
+ stageDir: string,
305
+ ): string {
306
+ if (exitCode === OCX_ELEVATED_STAGING_UNREADABLE) {
307
+ return `${failureLabel}: the elevated process could not read the staged task definition in `
308
+ + `${stageDir}. That directory is readable only by the account that staged it, so this `
309
+ + "happens when the UAC prompt was answered with a different administrator account. "
310
+ + "Approve the prompt as the signed-in user, or run the command again from a session "
311
+ + "already elevated as that user.";
312
+ }
313
+ return `${failureLabel} with exit code ${exitCode}.`;
314
+ }
315
+
316
+ /**
317
+ * Stage, elevate, and clean up — on every exit, including UAC cancellation and a
318
+ * synchronous spawn failure.
319
+ *
320
+ * A cleanup failure never replaces the registration failure it followed: an operator
321
+ * told only that a temp directory could not be removed would have no idea the task was
322
+ * never registered.
323
+ */
324
+ async function runStagedElevatedSchedulerRegistration(
325
+ taskName: string,
326
+ xml: string,
327
+ replace: boolean,
328
+ expectedExistingXml: string | undefined,
329
+ failureLabel: string,
330
+ ): Promise<void> {
331
+ const staged = stageElevatedSchedulerRegistration(xml, expectedExistingXml);
332
+ let failure: unknown;
333
+ try {
334
+ const exitCode = await runWindowsElevatedScheduledTaskRegistration(
335
+ taskName,
336
+ staged.xml,
337
+ replace,
338
+ staged.expectedExisting,
339
+ );
340
+ if (exitCode !== 0) {
341
+ failure = new Error(describeElevatedRegistrationFailure(failureLabel, exitCode, dirname(staged.xml.path)));
342
+ }
343
+ } catch (error) {
344
+ failure = error;
345
+ }
346
+ try {
347
+ staged.cleanup();
348
+ } catch (cleanupError) {
349
+ if (failure) {
350
+ throw new AggregateError(
351
+ [failure, cleanupError],
352
+ "Elevated Task Scheduler registration failed and its staging could not be cleaned up.",
353
+ );
354
+ }
355
+ throw cleanupError;
356
+ }
357
+ if (failure) throw failure;
358
+ }
359
+
168
360
  export function stageWindowsSchedulerRegistrationXml(
169
361
  attemptNonce: string,
170
362
  deps: WindowsSchedulerRegistrationStageDeps = {},
@@ -294,7 +486,8 @@ export async function registerFreshWindowsSchedulerTask(
294
486
  throw error;
295
487
  }
296
488
  // Register from the captured XML string inside the elevated process. Another
297
- // same-user process can mutate its own temp files, but cannot change this command.
489
+ // same-user process can mutate its own temp files, so the captured bytes are staged
490
+ // privately and the elevated script verifies their digest before registering them.
298
491
  // UAC can remain open for an arbitrary amount of time. Recheck the captured predecessor
299
492
  // before launch; the elevated helper repeats the same check after consent and before Force.
300
493
  assertReplacementPrecondition();
@@ -303,15 +496,13 @@ export async function registerFreshWindowsSchedulerTask(
303
496
  xml: string,
304
497
  replaceCurrent: boolean,
305
498
  previousXml?: string,
306
- ) => {
307
- const exitCode = await runWindowsElevatedScheduledTaskRegistration(
308
- taskName,
309
- xml,
310
- replaceCurrent,
311
- previousXml,
312
- );
313
- if (exitCode !== 0) throw new Error(`Background service install failed with exit code ${exitCode}.`);
314
- });
499
+ ) => runStagedElevatedSchedulerRegistration(
500
+ taskName,
501
+ xml,
502
+ replaceCurrent,
503
+ previousXml,
504
+ "Background service install failed",
505
+ ));
315
506
  await elevate(TASK, expectedXml, replace, expectedExistingXml);
316
507
  }
317
508
 
@@ -501,10 +692,13 @@ export async function restoreWindowsSchedulerTaskIfAbsent(registeredXml: string)
501
692
  ) {
502
693
  throw error;
503
694
  }
504
- const exitCode = await runWindowsElevatedScheduledTaskRegistration(TASK, registeredXml, false);
505
- if (exitCode !== 0) {
506
- throw new Error(`Task Scheduler rollback failed with exit code ${exitCode}.`);
507
- }
695
+ await runStagedElevatedSchedulerRegistration(
696
+ TASK,
697
+ registeredXml,
698
+ false,
699
+ undefined,
700
+ "Task Scheduler rollback failed",
701
+ );
508
702
  }
509
703
  const recoveredXml = statusWindowsXml();
510
704
  if (!windowsSchedulerRegistrationMatchesSnapshot(recoveredXml, registeredXml)) {
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { ELEVATION_REQUEST_TIMEOUT_MS, OCX_ELEVATED_PROTOCOL_FAILED, raceWithTimeout, resolveTrustedWindowsSchtasksExe, startElevatedSchtasksCreateAndRun, runWindowsElevated, toWindowsSchtasksError, WindowsElevationError, type ElevatedSchedulerOutcome, type ElevatedSchtasksCreateAndRunExecution, type ElevatedSchtasksCreateAndRunResult } from "../lib/windows-elevation";
7
7
  import { statusWinswRaw } from "../lib/winsw";
8
+ import { decodeWindowsTextBytes, type WindowsTextDecodeOptions } from "../lib/windows-text";
8
9
  import { isTestHomeGuardArmed } from "../lib/test-home-guard";
9
10
  import { TASK, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath, writeServiceInstallState } from "./state";
10
11
  import { buildWindowsSchtasksCreateArgs, windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy } from "./windows-taskxml";
@@ -16,28 +17,34 @@ import { WINSW_SERVICE_ID } from "../lib/winsw";
16
17
  * Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the
17
18
  * registered task document is UTF-16; reading that as UTF-8 makes every health check
18
19
  * fail ("registration present but unhealthy") and rolls back a successful elevated create.
20
+ *
21
+ * Redirected output is NOT always UTF-16. Its encoding follows the console output code
22
+ * page of the spawning process tree rather than the XML declaration, so on a zh-CN host
23
+ * (ACP/OEMCP 936) the bytes are GBK — including inside a no-console background service.
24
+ * Decoding those as UTF-8 turned a CJK account name in
25
+ * `<SessionStateChangeTrigger><UserId>` into U+FFFD, the trigger scope then failed to
26
+ * match the correctly resolved `[SID, MACHINE\<name>]`, and `ocx service repair`
27
+ * aborted at its recognition gate on a registration OpenCodex had itself created. The
28
+ * same mojibake rolled back fresh installs at post-create verification (#4691).
29
+ *
30
+ * The fix is entirely in byte decoding, before any XML is parsed. The trigger scope stays
31
+ * an exact identity comparison: forgiving a replacement character there would let two
32
+ * different non-ASCII accounts collapse to the same value, which is a worse failure than
33
+ * the refusal it replaces.
34
+ *
35
+ * `decodeWindowsTextBytes` is the decoder this project already built for this class
36
+ * (UTF-16 -> strict UTF-8 -> the locale's legacy code page), and it already fixed the
37
+ * sibling `whoami`/PowerShell decode in `src/lib/windows-user-principal.ts` (#2914, and
38
+ * #722 for CP949). This call site was the last one still ending in a lossy UTF-8 decode.
19
39
  */
20
- export function decodeSchtasksOutput(buffer: Buffer): string {
21
- if (buffer.length === 0) return "";
22
- const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe;
23
- const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff;
24
- const looksUtf16Le = buffer.length >= 4
25
- && buffer[1] === 0x00
26
- && buffer[3] === 0x00
27
- && buffer[0] !== 0x00;
28
- if (bomUtf16Le || looksUtf16Le) {
29
- return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim();
30
- }
31
- if (bomUtf16Be) {
32
- // Swap pairs then decode as utf16le.
33
- const swapped = Buffer.alloc(buffer.length - 2);
34
- for (let i = 2; i + 1 < buffer.length; i += 2) {
35
- swapped[i - 2] = buffer[i + 1]!;
36
- swapped[i - 1] = buffer[i]!;
37
- }
38
- return swapped.toString("utf16le").trim();
39
- }
40
- return buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
40
+ export function decodeSchtasksOutput(
41
+ buffer: Buffer,
42
+ options: WindowsTextDecodeOptions = {},
43
+ ): string {
44
+ // `options` exists so a test can pin the code page; every production call passes the
45
+ // buffer alone and uses the active Intl locale, which is available to a service with no
46
+ // console because the selection reads the process locale rather than a console handle.
47
+ return decodeWindowsTextBytes(buffer, options);
41
48
  }
42
49
 
43
50
  function runFile(file: string, args: string[]): string {
package/src/service.ts CHANGED
@@ -19,7 +19,7 @@ export { decodeSchtasksOutput, setQuerySchtasksForTests, formatWindowsSchedulerS
19
19
  export type { WindowsSchedulerXmlState } from "./service/windows-taskxml";
20
20
  export { buildWindowsServiceScript, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsLauncherVbs, buildWindowsTaskXml, buildWindowsTaskXmlDocument, windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy, readWindowsSchedulerXmlState } from "./service/windows-taskxml";
21
21
  export type { WindowsSchedulerRegistrationStageDeps, FreshWindowsSchedulerRegistrationDeps, RemoveNativeWindowsServiceDeps } from "./service/windows-ops";
22
- export { windowsListenPort, winswListenPort, writeServiceDefinitionFile, definitionCarriesCredential, stageWindowsSchedulerRegistrationXml, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, assertWindowsNativeServiceAccountSupported, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, classifyWindowsServiceStop } from "./service/windows-ops";
22
+ export { windowsListenPort, winswListenPort, writeServiceDefinitionFile, definitionCarriesCredential, stageWindowsSchedulerRegistrationXml, stageElevatedSchedulerRegistration, describeElevatedRegistrationFailure, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, assertWindowsNativeServiceAccountSupported, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, classifyWindowsServiceStop } from "./service/windows-ops";
23
23
  export type { ServiceRepairVerb, RepairServiceDeps } from "./service/repair";
24
24
  export { repairService } from "./service/repair";
25
25
  export type { ServiceInstallPreparationDeps, FreshWindowsSchedulerInstallDeps, ServiceStopOutcome, ServiceUninstallOutcome } from "./service/orchestration";
@@ -882,6 +882,36 @@ export interface OcxConfig {
882
882
  * binding under either setting -- neither is a cache-affinity preference.
883
883
  */
884
884
  cacheAffinity?: boolean;
885
+ /**
886
+ * Operator-declared quota domains: groups of credential ids that demonstrably share
887
+ * one upstream usage limit (#4546, wp6). Members of one group count once toward
888
+ * available capacity, and a quota refusal inside a group is never answered by
889
+ * rotating to another member -- the limit is the same, so the move would pay a cold
890
+ * prefix for zero new capacity.
891
+ *
892
+ * Declared groups speak only to quota. Sharing a usage limit says nothing about
893
+ * prompt-cache compatibility, which keeps its own provider-documented domain.
894
+ * Absent or empty means no declared grouping, so an unconfigured install behaves
895
+ * exactly as before.
896
+ *
897
+ * A declaration has to mean exactly one thing, so the config rejects the spellings
898
+ * that could mean two. Credential ids are provider-scoped elsewhere (the auth store
899
+ * keys an account by provider and id), so each member is written
900
+ * `"<provider>:<credential-id>"` -- a bare `"acct-1"` names one credential per
901
+ * provider and would merge unrelated domains. The provider segment is matched
902
+ * case-insensitively through the usual aliases, so `chatgpt:` and `codex:` both mean
903
+ * OpenAI. Group ids must be unique, `credentials` must be non-empty, and a credential
904
+ * may belong to at most one group; a declaration that breaks any of those is rejected
905
+ * on write and dropped with a warning on load, never resolved by list order.
906
+ */
907
+ credentialGroups?: Array<{
908
+ /** Operator-chosen group identifier; only equality matters. */
909
+ id: string;
910
+ /** Provider-qualified credential ids (`"<provider>:<credential-id>"`), non-empty. */
911
+ credentials: string[];
912
+ /** Free-text provenance note for the operator's own records. */
913
+ note?: string;
914
+ }>;
885
915
  };
886
916
  /** Active pool account id for next session. undefined = main (passthrough as-is). */
887
917
  activeCodexAccountId?: string;
@@ -981,6 +1011,7 @@ export type OcxAccountPoolQuotaWindow = "five-hour" | "weekly" | "max-utilizatio
981
1011
 
982
1012
  export type OcxComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window";
983
1013
  export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
1014
+ export type OcxComboDefaultEffortMode = "fallback" | "force";
984
1015
 
985
1016
  /**
986
1017
  * How a combo derives the reasoning ladder it publishes to the picker.
@@ -1016,8 +1047,10 @@ export interface OcxComboConfig {
1016
1047
  cooldownMs?: number;
1017
1048
  /** Maximum wait for an eligible target cooldown to expire before failing closed. Default 0; range 0..600000, per selection attempt. */
1018
1049
  waitForCooldownMs?: number;
1019
- /** Used when the client omits reasoning.effort. null/omitted leaves the target default unchanged. */
1050
+ /** Used as a fallback when the client omits reasoning.effort, or as an override in `force` mode. null/omitted leaves the target default unchanged. */
1020
1051
  defaultEffort?: OcxComboDefaultEffort | null;
1052
+ /** `force` makes the combo default override a valid client effort. Omitted / `fallback` preserves client precedence. */
1053
+ defaultEffortMode?: OcxComboDefaultEffortMode;
1021
1054
  /**
1022
1055
  * Picker-ladder derivation policy. Omitted / `"strict"` keeps the legacy rule where an
1023
1056
  * explicitly empty target ladder suppresses the whole combo's effort control.
@@ -154,9 +154,11 @@ export interface OcxAssistantMessage {
154
154
  model?: string;
155
155
  timestamp: number;
156
156
  /**
157
- * Kiro `reasoningContent.redactedContent` for THIS assistant turn — an opaque encrypted blob
158
- * Kiro replays to preserve model reasoning across turns. Provider-specific and unrenderable, so
159
- * it rides the message rather than a content part: any other adapter simply ignores it.
157
+ * Kiro's encrypted reasoning blob for THIS assistant turn — the opaque value from the turn's
158
+ * `reasoningContentEvent` (`signature` for the GPT-5.6 family, `redactedContent` for the base64
159
+ * shape), tagged with the wire field it must be replayed on (see kiro/reasoning.ts). Kiro
160
+ * replays it to preserve model reasoning across turns. Provider-specific and unrenderable, so it
161
+ * rides the message rather than a content part: any other adapter simply ignores it.
160
162
  */
161
163
  kiroRedactedReasoning?: string;
162
164
  }
@@ -317,8 +319,9 @@ export type AdapterEvent =
317
319
  // opaque redacted_thinking blocks. Both must be replayed verbatim or tool-use turns 400.
318
320
  | { type: "thinking_signature"; signature: string }
319
321
  | { type: "redacted_thinking"; data: string }
320
- // Kiro reasoning round-trip: the encrypted `redactedContent` blob for the CURRENT assistant turn.
321
- // Never rendered — it only rides the reasoning item's envelope so the next request can replay it.
322
+ // Kiro reasoning round-trip: the encrypted reasoning blob for the CURRENT assistant turn, tagged
323
+ // with the wire field it arrived on. Never rendered — it only rides the reasoning item's envelope
324
+ // so the next request can replay it verbatim.
322
325
  | { type: "kiro_redacted_reasoning"; data: string }
323
326
  | { type: "reasoning_raw_delta"; text: string }
324
327
  | { type: "tool_call_start"; id: string; name: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata }
@@ -67,6 +67,30 @@ const CODE_MODE_HELPER_TOOL_NAMES = [
67
67
  */
68
68
  export const CODE_MODE_EXEC_TOOL_NAME = "exec";
69
69
 
70
+ /**
71
+ * Spellings that may never be MANUFACTURED as a bare alias for a namespaced tool.
72
+ *
73
+ * A bare alias is an ordinary compatibility affordance -- providers echo a namespaced tool
74
+ * without its prefix, and restoring the identity needs the bare spelling registered. For these
75
+ * six it is also an authorization decision, because a declared-name set is what
76
+ * `normalizeDeclaredToolName` and `declaresCodeModeExec` read: bare `exec` turns nested-helper
77
+ * normalization on for a catalog that never declared the shell, bare `exec_command` or
78
+ * `shell_command` turns it off for one that did, and the rest are accepted as declared calls the
79
+ * caller only ever authorized under a namespace.
80
+ *
81
+ * This is a property of the SPELLING, not of the namespace that declared it and not of the reason
82
+ * the alias was being added. It lives here, beside the names it protects, because every site that
83
+ * builds a declared-name set has to apply the same list -- the two that kept their own copies each
84
+ * drifted, once to a single namespace and once to a single name.
85
+ *
86
+ * A genuine namespace-free declaration is NOT covered: that is the caller declaring the tool, not
87
+ * a namespace being discarded to synthesize a bare name.
88
+ */
89
+ export const NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES: ReadonlySet<string> = new Set<string>([
90
+ CODE_MODE_EXEC_TOOL_NAME,
91
+ ...CODE_MODE_HELPER_TOOL_NAMES,
92
+ ]);
93
+
70
94
  /**
71
95
  * Normalizes provider-emitted tool names against declared tool catalogs.
72
96
  *
package/src/types.ts CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  isAllowedToolChoice,
17
17
  toolChoiceToolPredicate,
18
18
  declaresCodeModeExec,
19
+ NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES,
19
20
  } from "./types/tools";
20
21
 
21
22
  export type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire";
@@ -74,6 +75,7 @@ export type {
74
75
  OcxAccountPoolQuotaWindow,
75
76
  OcxComboStrategy,
76
77
  OcxComboDefaultEffort,
78
+ OcxComboDefaultEffortMode,
77
79
  OcxComboReasoningEffortMode,
78
80
  OcxComboTarget,
79
81
  OcxComboConfig,
@@ -481,6 +481,16 @@ export async function runUpdate(): Promise<void> {
481
481
  " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.",
482
482
  );
483
483
  }
484
+ if (decision.reason === "history-deferred") {
485
+ // Not the same warning: nothing was restored here. Saying "history metadata is
486
+ // incomplete" would imply config and catalog came back, and an operator who
487
+ // believed that would not know a teardown is still owed.
488
+ console.warn(
489
+ "⚠️ The shared teardown was refused by the Codex history preflight and restored nothing.\n" +
490
+ " Config, catalog, history and provenance were preserved, and the teardown receipt was kept.\n" +
491
+ " The proxy is down, so the update continues; close the Codex app and run 'ocx stop' once afterwards to finish the restore.",
492
+ );
493
+ }
484
494
  }
485
495
 
486
496
  console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
@@ -1,2 +1,3 @@
1
1
  /** Declaration for the plain-ESM stop contract shared with `bin/ocx.mjs`. */
2
2
  export declare const STOP_HISTORY_INCOMPLETE_EXIT_CODE: 79;
3
+ export declare const STOP_HISTORY_DEFERRED_EXIT_CODE: 80;
@@ -13,3 +13,22 @@
13
13
  * the child's code faithfully enough to propagate the confusion.
14
14
  */
15
15
  export const STOP_HISTORY_INCOMPLETE_EXIT_CODE = 79;
16
+
17
+ /**
18
+ * The exit code `ocx stop` uses to say "the proxy is down and the shared teardown was
19
+ * refused before it changed anything" (#4718).
20
+ *
21
+ * This is NOT 79. Seventy-nine means the teardown ran: config and catalog came back to
22
+ * their native values and only the Codex history metadata could not be finalized, so the
23
+ * receipt is discharged. Eighty means the Codex history preflight refused FIRST, so
24
+ * config, catalog, history and provenance are all untouched, the client is still routed
25
+ * at the proxy that just stopped, and the receipt stays outstanding for a later stop.
26
+ *
27
+ * Collapsing the two would be a data-loss bug in the quiet direction: a caller reading 79
28
+ * discharges an obligation that was never performed.
29
+ *
30
+ * Eighty sits in the same unoccupied window as 79 — above `sysexits.h` (64-78), below
31
+ * `128 + signal`, and outside 0, 1, 2, 4, 64 and 130, which are the codes this CLI and its
32
+ * dispatcher already emit.
33
+ */
34
+ export const STOP_HISTORY_DEFERRED_EXIT_CODE = 80;
@@ -6,5 +6,5 @@ export declare function decidePostStopUpdate(input: {
6
6
  teardownOutstanding?: boolean;
7
7
  }): {
8
8
  proceed: boolean;
9
- reason: "stop-failed" | "runtime-state" | "teardown-outstanding" | "proxy-live" | "proxy-unknown" | "history-only" | "ok";
9
+ reason: "stop-failed" | "runtime-state" | "teardown-outstanding" | "proxy-live" | "proxy-unknown" | "history-only" | "history-deferred" | "ok";
10
10
  };
@@ -1,4 +1,4 @@
1
- import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs";
1
+ import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs";
2
2
 
3
3
  /**
4
4
  * May an update replace package files after `ocx stop` returned?
@@ -22,13 +22,22 @@ import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs";
22
22
  * absence, and replacing files under a live server leaves it running a mix of old and
23
23
  * new modules.
24
24
  * - `ok` / `history-only` — proceed; the second also prints the manifest warning.
25
+ * - `history-deferred` — proceed; the stop is down but restored nothing, because the
26
+ * Codex history preflight refused first (#4718). The receipts it kept are the ONLY
27
+ * obligations it left, which the child proved before choosing this status, so
28
+ * `teardownOutstanding` seeing them is expected rather than disqualifying. Every other
29
+ * gate still applies: runtime records and a live or unreadable endpoint abort exactly
30
+ * as they do for a clean stop, because package replacement under a live server is the
31
+ * danger this function exists to prevent, and a history refusal says nothing about it.
25
32
  */
26
33
  export function decidePostStopUpdate({ status, hasRuntimeState, liveness, teardownOutstanding = false }) {
27
34
  const historyOnly = status === STOP_HISTORY_INCOMPLETE_EXIT_CODE;
28
- if (status !== 0 && !historyOnly) return { proceed: false, reason: "stop-failed" };
35
+ const historyDeferred = status === STOP_HISTORY_DEFERRED_EXIT_CODE;
36
+ if (status !== 0 && !historyOnly && !historyDeferred) return { proceed: false, reason: "stop-failed" };
29
37
  if (hasRuntimeState) return { proceed: false, reason: "runtime-state" };
30
- if (teardownOutstanding) return { proceed: false, reason: "teardown-outstanding" };
38
+ if (teardownOutstanding && !historyDeferred) return { proceed: false, reason: "teardown-outstanding" };
31
39
  if (liveness === "live") return { proceed: false, reason: "proxy-live" };
32
40
  if (liveness !== "dead") return { proceed: false, reason: "proxy-unknown" };
41
+ if (historyDeferred) return { proceed: true, reason: "history-deferred" };
33
42
  return { proceed: true, reason: historyOnly ? "history-only" : "ok" };
34
43
  }