@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,153 @@
1
+ /** Bounds diagnostic retention, never the bytes delivered to the caller. */
2
+ export const MAX_RESPONSE_LOG_INSPECTION_BYTES = 32 * 1024 * 1024;
3
+ export const MAX_NON_JSON_ERROR_INSPECTION_BYTES = 8 * 1024;
4
+ const INSPECTION_BLOCK_BYTES = 64 * 1024;
5
+
6
+ export type ResponseLogBodyEnd = "eof" | "read_error" | "cancel";
7
+
8
+ export interface ResponseLogBodyOptions {
9
+ json: boolean;
10
+ inspect: (text: string) => void;
11
+ finalize: (reason: ResponseLogBodyEnd) => void;
12
+ /** Internal test seam; this is not a provider-controlled limit. */
13
+ maxInspectionBytes?: number;
14
+ }
15
+
16
+ /** Fixed-size blocks bound both retained bytes and per-chunk bookkeeping. */
17
+ class ResponseLogInspection {
18
+ private blocks: Uint8Array[] = [];
19
+ private bytes = 0;
20
+ private overflowed = false;
21
+
22
+ constructor(private readonly json: boolean, private readonly limit: number) {}
23
+
24
+ append(chunk: Uint8Array): void {
25
+ if (this.overflowed || chunk.byteLength === 0) return;
26
+ if (this.json && chunk.byteLength > this.limit - this.bytes) {
27
+ // A JSON prefix is not an authoritative response. Drop it immediately,
28
+ // without either truncating the delivery stream or retaining later bytes.
29
+ this.overflowed = true;
30
+ this.dispose();
31
+ return;
32
+ }
33
+ let remaining = Math.min(chunk.byteLength, this.limit - this.bytes);
34
+ let offset = 0;
35
+ while (remaining > 0) {
36
+ const blockOffset = this.bytes % INSPECTION_BLOCK_BYTES;
37
+ if (blockOffset === 0) {
38
+ this.blocks.push(new Uint8Array(Math.min(INSPECTION_BLOCK_BYTES, this.limit - this.bytes)));
39
+ }
40
+ const block = this.blocks[this.blocks.length - 1]!;
41
+ const length = Math.min(remaining, block.byteLength - blockOffset);
42
+ block.set(chunk.subarray(offset, offset + length), blockOffset);
43
+ this.bytes += length;
44
+ offset += length;
45
+ remaining -= length;
46
+ }
47
+ }
48
+
49
+ text(reason: ResponseLogBodyEnd): string | undefined {
50
+ // Even a syntactically valid JSON prefix must not update usage/model
51
+ // metadata when the transport did not reach EOF.
52
+ if (this.json && (reason !== "eof" || this.overflowed)) return undefined;
53
+ const combined = new Uint8Array(this.bytes);
54
+ let offset = 0;
55
+ for (const block of this.blocks) {
56
+ const length = Math.min(block.byteLength, this.bytes - offset);
57
+ combined.set(block.subarray(0, length), offset);
58
+ offset += length;
59
+ }
60
+ return new TextDecoder().decode(combined);
61
+ }
62
+
63
+ dispose(): void {
64
+ this.blocks.length = 0;
65
+ this.bytes = 0;
66
+ }
67
+ }
68
+
69
+ /** Optional diagnostics must not change the response's transport outcome. */
70
+ function bestEffort(callback: () => void): void {
71
+ try {
72
+ callback();
73
+ } catch {
74
+ return;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Forward one upstream read per downstream pull, with bounded side inspection.
80
+ * EOF, read failure and cancellation each finalize at most once. Cancellation
81
+ * does not await the upstream cancel promise: one branch of a tee can otherwise
82
+ * wait for a sibling that the same caller intends to consume or cancel later.
83
+ */
84
+ export function createBoundedResponseLogBody(
85
+ body: ReadableStream<Uint8Array>,
86
+ options: ResponseLogBodyOptions,
87
+ ): ReadableStream<Uint8Array> {
88
+ const limit = options.maxInspectionBytes ?? (options.json
89
+ ? MAX_RESPONSE_LOG_INSPECTION_BYTES
90
+ : MAX_NON_JSON_ERROR_INSPECTION_BYTES);
91
+ if (!Number.isSafeInteger(limit) || limit < 0) {
92
+ throw new RangeError("Response log inspection limit must be a non-negative safe integer");
93
+ }
94
+ const inspection = new ResponseLogInspection(options.json, limit);
95
+ const reader = body.getReader();
96
+ let ended = false;
97
+ let inspectionFailed = false;
98
+
99
+ const release = () => bestEffort(() => reader.releaseLock());
100
+ const finish = (reason: ResponseLogBodyEnd) => {
101
+ if (ended) return;
102
+ ended = true; // Set before callbacks or a pending read resumes.
103
+ try {
104
+ if (!inspectionFailed) {
105
+ bestEffort(() => {
106
+ const text = inspection.text(reason);
107
+ if (text !== undefined) options.inspect(text);
108
+ });
109
+ }
110
+ } finally {
111
+ inspection.dispose();
112
+ bestEffort(() => options.finalize(reason));
113
+ }
114
+ };
115
+
116
+ return new ReadableStream<Uint8Array>({
117
+ async pull(controller) {
118
+ if (ended) return;
119
+ let result: Awaited<ReturnType<typeof reader.read>>;
120
+ try {
121
+ result = await reader.read();
122
+ } catch (error) {
123
+ if (ended) return; // Cancellation owns its pending-read settlement.
124
+ finish("read_error");
125
+ release();
126
+ controller.error(error);
127
+ return;
128
+ }
129
+ if (ended) return;
130
+ if (result.done) {
131
+ finish("eof");
132
+ release();
133
+ controller.close();
134
+ return;
135
+ }
136
+ if (!inspectionFailed) {
137
+ try {
138
+ inspection.append(result.value);
139
+ } catch {
140
+ inspectionFailed = true;
141
+ inspection.dispose();
142
+ }
143
+ }
144
+ // Do not decode/re-encode transport bytes, including malformed UTF-8.
145
+ controller.enqueue(result.value);
146
+ },
147
+ cancel(reason) {
148
+ finish("cancel");
149
+ bestEffort(() => { void reader.cancel(reason).catch(() => undefined); });
150
+ release();
151
+ },
152
+ }, { highWaterMark: 0 });
153
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Codex pool account-change conversation-state portability (#4546).
3
+ *
4
+ * OpenAI `encrypted_content` blobs and `previous_response_id` are bound to the
5
+ * account that minted them. When pool routing serves a live conversation on a
6
+ * different account, the next turn must drop that state once before dispatch so
7
+ * the new account can continue from readable history instead of rejecting the
8
+ * ciphertext.
9
+ *
10
+ * The issuer association lives next to thread affinity in `src/codex/routing.ts`.
11
+ */
12
+
13
+ import type { CodexAuthContext } from "../../codex/auth-context";
14
+ import {
15
+ peekConversationStateIssuer,
16
+ rememberConversationStateIssuer,
17
+ } from "../../codex/routing";
18
+ import type { OcxParsedRequest } from "../../types";
19
+ import type { RequestLogContext } from "../request-log";
20
+ import { formatErrorResponse } from "../../bridge/errors";
21
+
22
+ export type ConversationStateScrubReason = "account-change";
23
+
24
+ /**
25
+ * Cause AND remedy, because this refusal does not clear itself.
26
+ *
27
+ * Dropping a `previous_response_id` costs one cold turn and the conversation continues. This is
28
+ * not that. The file reference lives in the conversation's history, so once pool rotation has
29
+ * moved a conversation carrying an attachment, every following turn presents the same reference
30
+ * and is refused the same way. A caller told only that the reference is invalid will send the
31
+ * same request back and watch the conversation appear dead, which is the one outcome a refusal
32
+ * is supposed to prevent. So the text says what happened, that it will keep happening, and the
33
+ * two things that actually end it.
34
+ *
35
+ * Names no account id, no file id, and no conversation id.
36
+ */
37
+ export const ACCOUNT_CHANGE_FILE_SCOPE_MESSAGE =
38
+ "This conversation is now being served by a different account than the one its uploaded files "
39
+ + "were sent to, and an uploaded file can only be read by the account that received it. The "
40
+ + "request was not sent upstream, and no file was removed from it. Because the references stay "
41
+ + "in this conversation's history, later turns will be refused the same way until this is "
42
+ + "resolved: re-upload the files so they are issued by the account now serving this "
43
+ + "conversation, or start a new conversation for them. Sending the same request again "
44
+ + "unchanged will not clear it.";
45
+
46
+ export type PortabilityDenial =
47
+ | "previous-response-id"
48
+ | "provider-conversation-id"
49
+ | "uploaded-file-ids"
50
+ | "encrypted-reasoning";
51
+
52
+ /**
53
+ * The parts of a request that bind it to the credential that produced them.
54
+ * Presence is what matters; the values stay opaque so nothing here logs ids.
55
+ */
56
+ export interface ConversationStateCarriers {
57
+ readonly previousResponseId?: string | null;
58
+ readonly providerConversationId?: string | null;
59
+ readonly fileIds?: readonly string[];
60
+ readonly encryptedReasoning?: unknown;
61
+ }
62
+
63
+ export type PortabilityVerdict =
64
+ | { readonly portable: true }
65
+ | { readonly portable: false; readonly reason: PortabilityDenial };
66
+
67
+ function present(value: unknown): boolean {
68
+ if (value === undefined || value === null) return false;
69
+ if (typeof value === "string" || Array.isArray(value)) return value.length > 0;
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * Whether a request's conversational state can move credentials at all.
75
+ *
76
+ * `src/routing/identity-domains.ts` owns this decision once that module lands
77
+ * on this integration line (#4546). Keep the check in this one function so it
78
+ * can be swapped for the shared export without hunting call sites.
79
+ */
80
+ export function canPortConversationState(
81
+ state: ConversationStateCarriers,
82
+ ): PortabilityVerdict {
83
+ if (present(state.previousResponseId)) {
84
+ return { portable: false, reason: "previous-response-id" };
85
+ }
86
+ if (present(state.providerConversationId)) {
87
+ return { portable: false, reason: "provider-conversation-id" };
88
+ }
89
+ if (present(state.fileIds)) {
90
+ return { portable: false, reason: "uploaded-file-ids" };
91
+ }
92
+ if (present(state.encryptedReasoning)) {
93
+ return { portable: false, reason: "encrypted-reasoning" };
94
+ }
95
+ return { portable: true };
96
+ }
97
+
98
+ function providerConversationIdFromBody(body: Record<string, unknown>): string | undefined {
99
+ const conversation = body.conversation;
100
+ if (typeof conversation === "string" && conversation.trim()) return conversation.trim();
101
+ if (conversation && typeof conversation === "object" && !Array.isArray(conversation)) {
102
+ const id = (conversation as { id?: unknown }).id;
103
+ if (typeof id === "string" && id.trim()) return id.trim();
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ function collectFileIds(input: unknown): string[] {
109
+ const ids: string[] = [];
110
+ if (!Array.isArray(input)) return ids;
111
+ for (const item of input) {
112
+ if (!item || typeof item !== "object") continue;
113
+ const record = item as Record<string, unknown>;
114
+ if (typeof record.file_id === "string" && record.file_id.trim()) ids.push(record.file_id);
115
+ if (Array.isArray(record.file_ids)) {
116
+ for (const id of record.file_ids) {
117
+ if (typeof id === "string" && id.trim()) ids.push(id);
118
+ }
119
+ }
120
+ for (const key of ["content", "output"]) {
121
+ const parts = record[key];
122
+ if (!Array.isArray(parts)) continue;
123
+ for (const part of parts) {
124
+ if (!part || typeof part !== "object") continue;
125
+ const partRecord = part as Record<string, unknown>;
126
+ if (typeof partRecord.file_id === "string" && partRecord.file_id.trim()) {
127
+ ids.push(partRecord.file_id);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ return ids;
133
+ }
134
+
135
+ function hasEncryptedReasoning(input: unknown): boolean {
136
+ if (!Array.isArray(input)) return false;
137
+ for (const item of input) {
138
+ if (!item || typeof item !== "object") continue;
139
+ const record = item as Record<string, unknown>;
140
+ if (typeof record.encrypted_content === "string" && record.encrypted_content.length > 0) {
141
+ return true;
142
+ }
143
+ for (const key of ["content", "output"]) {
144
+ const parts = record[key];
145
+ if (!Array.isArray(parts)) continue;
146
+ for (const part of parts) {
147
+ if (!part || typeof part !== "object") continue;
148
+ const encrypted = (part as { encrypted_content?: unknown }).encrypted_content;
149
+ if (typeof encrypted === "string" && encrypted.length > 0) return true;
150
+ }
151
+ }
152
+ }
153
+ return false;
154
+ }
155
+
156
+ export function collectConversationStateCarriers(body: unknown): ConversationStateCarriers {
157
+ if (!body || typeof body !== "object" || Array.isArray(body)) return {};
158
+ const record = body as Record<string, unknown>;
159
+ const previousResponseId = typeof record.previous_response_id === "string"
160
+ ? record.previous_response_id
161
+ : undefined;
162
+ return {
163
+ previousResponseId,
164
+ providerConversationId: providerConversationIdFromBody(record),
165
+ fileIds: collectFileIds(record.input),
166
+ encryptedReasoning: hasEncryptedReasoning(record.input) ? true : undefined,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Does this body reference an uploaded file?
172
+ *
173
+ * Answerable from the body alone, which is what lets an alternate-account path ask BEFORE it
174
+ * resolves an alternate: the answer cannot depend on which account is chosen, because an
175
+ * uploaded file is readable only by the account it was sent to (#4710).
176
+ *
177
+ * `fileIds` is optional on the carrier type, so the emptiness test lives here rather than being
178
+ * rewritten at each caller. One of those rewrites already dereferenced it directly.
179
+ */
180
+ export function conversationCarriesUploadedFiles(body: unknown): boolean {
181
+ const fileIds = collectConversationStateCarriers(body).fileIds;
182
+ return fileIds !== undefined && fileIds.length > 0;
183
+ }
184
+
185
+
186
+ /**
187
+ * Drop account-bound continuation from a request body in place. Readable user
188
+ * messages and plaintext survive; ciphertext and continuation ids do not.
189
+ */
190
+ export function scrubUnportableConversationStateInPlace(body: unknown): boolean {
191
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
192
+ const record = body as Record<string, unknown>;
193
+ let changed = false;
194
+ if (typeof record.previous_response_id === "string") {
195
+ delete record.previous_response_id;
196
+ changed = true;
197
+ }
198
+ if (record.conversation != null) {
199
+ delete record.conversation;
200
+ changed = true;
201
+ }
202
+ // Encrypted reasoning and compaction ciphertext are deliberately NOT touched here. #2247
203
+ // already strips them when a pooled thread moves accounts, and in a specific shape: the
204
+ // reasoning item keeps its readable summary with an emptied content array, and the compaction
205
+ // item becomes an operator-readable note. Stripping again from this side produced a different
206
+ // shape and broke that contract for no gain. What #2247 does not cover, and what this function
207
+ // owns, is the continuation state naming server-side objects the new account cannot read:
208
+ // `previous_response_id` and a provider-side conversation id.
209
+ return changed;
210
+ }
211
+
212
+ export function conversationStateBindingFromAuth(
213
+ authCtx: CodexAuthContext,
214
+ fallbackAffinityKey?: string | null,
215
+ ): { accountId: string; bindingKey: string } | null {
216
+ if (authCtx.kind !== "pool" && authCtx.kind !== "main-pool") return null;
217
+ const bindingKey = authCtx.affinityKey ?? fallbackAffinityKey ?? undefined;
218
+ if (!bindingKey || !authCtx.accountId) return null;
219
+ return { accountId: authCtx.accountId, bindingKey };
220
+ }
221
+
222
+ export function rememberServingConversationStateIssuer(
223
+ authCtx: CodexAuthContext,
224
+ fallbackAffinityKey?: string | null,
225
+ ): void {
226
+ const binding = conversationStateBindingFromAuth(authCtx, fallbackAffinityKey);
227
+ if (!binding) return;
228
+ rememberConversationStateIssuer(binding.bindingKey, binding.accountId);
229
+ }
230
+
231
+ export interface ApplyAccountChangeConversationStateScrubArgs {
232
+ body: unknown;
233
+ bindingKey: string;
234
+ servingAccountId: string;
235
+ /** Account this request body was prepared for, when this is an in-request move. */
236
+ priorAccountId?: string | null;
237
+ parsed?: Pick<OcxParsedRequest, "previousResponseId" | "_stripReasoningEncryptedContent">;
238
+ logCtx?: RequestLogContext;
239
+ }
240
+
241
+ /**
242
+ * If the serving account is not the issuer of the carried state, strip that
243
+ * state from the outbound body before dispatch. One cold turn, not a permanent
244
+ * downgrade: the next successful serve records the new issuer.
245
+ */
246
+ export function applyAccountChangeConversationStateScrub(
247
+ args: ApplyAccountChangeConversationStateScrubArgs,
248
+ ): boolean {
249
+ const { body, bindingKey, servingAccountId, priorAccountId, parsed, logCtx } = args;
250
+ if (!servingAccountId || !bindingKey) return false;
251
+ const issuer = peekConversationStateIssuer(bindingKey);
252
+ const accountChanged = (issuer != null && issuer !== servingAccountId)
253
+ || (priorAccountId != null && priorAccountId !== servingAccountId);
254
+ if (!accountChanged) return false;
255
+ if (canPortConversationState(collectConversationStateCarriers(body)).portable) return false;
256
+ const scrubbed = scrubUnportableConversationStateInPlace(body);
257
+ if (!scrubbed) return false;
258
+ if (parsed) {
259
+ delete parsed.previousResponseId;
260
+ parsed._stripReasoningEncryptedContent = true;
261
+ }
262
+ if (logCtx && logCtx.conversationStateScrub !== "account-change") {
263
+ console.warn(
264
+ "[opencodex] dropped continuation state after a Codex pool account change; continuing fresh",
265
+ );
266
+ logCtx.conversationStateScrub = "account-change";
267
+ } else if (logCtx) {
268
+ logCtx.conversationStateScrub = "account-change";
269
+ }
270
+ return true;
271
+ }
272
+
273
+ /**
274
+ * Refuse an account change that would carry an uploaded-file reference to an account that
275
+ * cannot read it (#4710).
276
+ *
277
+ * The classifier has always called `file_id` account-bound, and the scrubber has always removed
278
+ * only `previous_response_id` and `conversation`. A body whose ONLY account-bound state was an
279
+ * uploaded file therefore reported nothing scrubbed and was replayed unchanged against the new
280
+ * account, which is the one case the safety fix was supposed to cover.
281
+ *
282
+ * Deleting the references is not the fix. A file reference is not continuation state the model
283
+ * can do without: it is content the caller attached, and silently dropping it answers a
284
+ * different question than the one that was asked, with no way for the caller to tell. Pinning
285
+ * the request to the issuing account is not available either — every call site resolves and
286
+ * materialises its credential before reaching here, and the retry sites are reached precisely
287
+ * because the issuing account just refused the request.
288
+ *
289
+ * So the move is refused, before dispatch, and the caller is told exactly what to do about it.
290
+ * A 400 rather than a 409: re-uploading is required, and a retryable status would invite the
291
+ * same request back unchanged.
292
+ */
293
+ export function accountChangeFileReferenceRefusal(
294
+ args: Pick<ApplyAccountChangeConversationStateScrubArgs, "body" | "bindingKey" | "servingAccountId" | "priorAccountId">,
295
+ ): Response | undefined {
296
+ const { body, bindingKey, servingAccountId, priorAccountId } = args;
297
+ if (!servingAccountId || !bindingKey) return undefined;
298
+ const issuer = peekConversationStateIssuer(bindingKey);
299
+ const accountChanged = (issuer != null && issuer !== servingAccountId)
300
+ || (priorAccountId != null && priorAccountId !== servingAccountId);
301
+ if (!accountChanged) return undefined;
302
+ // Checked against the carriers directly rather than through the portability verdict: that
303
+ // verdict reports the FIRST reason it finds, so a body carrying both a previous response id
304
+ // and a file reference reports only the former and the file would slip through the scrub.
305
+ if (!conversationCarriesUploadedFiles(body)) return undefined;
306
+ return formatErrorResponse(400, "invalid_request_error", ACCOUNT_CHANGE_FILE_SCOPE_MESSAGE);
307
+ }