@bitkyc08/opencodex 2.52.0-preview.20260912 → 2.53.0-preview.20260913

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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-D_t6sCWs.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -0,0 +1,233 @@
1
+ import { existsSync, readFileSync, rmSync } from "node:fs";
2
+
3
+ import { MAX_REMOTE_CATALOG_BYTES } from "../../server/catalog-download";
4
+ import { readBoundedResponseBytes } from "../../lib/bounded-body";
5
+ import { withCatalogWriteSerialization, type CatalogSerializationOutcome, type CatalogWritePermit } from "../catalog-write-serialization";
6
+ import { replaceActiveCodexCatalog } from "../internal/catalog-writer";
7
+ import { resetCodexAppServerCatalogStateCache } from "../app-server-processes";
8
+ import { getCodexHome } from "../paths";
9
+ import { readCodexCatalogPathForHome } from "./parsing";
10
+ import { invalidateCodexModelsCacheWithPermit } from "./sync";
11
+
12
+ const DEFAULT_TIMEOUT_MS = 15_000;
13
+ const MAX_MODELS = 2_000;
14
+ const MAX_SLUG_BYTES = 512;
15
+ const ALLOWED_MODALITIES = new Set(["text", "image", "audio"]);
16
+
17
+ export type RemoteCatalogFailureCode =
18
+ | "url_invalid" | "insecure_http_refused" | "credential_invalid" | "request_failed"
19
+ | "redirect_refused" | "http_error" | "body_too_large" | "body_invalid"
20
+ | "catalog_invalid" | "write_failed" | "lock_busy" | "lock_database" | "unsafe_path";
21
+
22
+ export class RemoteCatalogError extends Error {
23
+ constructor(readonly code: RemoteCatalogFailureCode, message: string, readonly status?: number) {
24
+ super(message);
25
+ this.name = "RemoteCatalogError";
26
+ }
27
+ }
28
+
29
+ export interface RemoteCatalogDocument extends Record<string, unknown> {
30
+ models: Record<string, unknown>[];
31
+ }
32
+
33
+ export interface PullRemoteCatalogOptions {
34
+ token?: string;
35
+ timeoutMs?: number;
36
+ maxBytes?: number;
37
+ fetchImpl?: typeof fetch;
38
+ codexHome?: string;
39
+ }
40
+
41
+ export interface PullRemoteCatalogResult {
42
+ status: "updated" | "unchanged";
43
+ catalogWritten: boolean;
44
+ cacheSynced: boolean;
45
+ codexHome: string;
46
+ catalogPath: string;
47
+ modelCount: number;
48
+ }
49
+
50
+ function isLoopback(hostname: string): boolean {
51
+ const host = hostname.toLowerCase();
52
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
53
+ }
54
+
55
+ export function validateRemoteCatalogUrl(input: string): URL {
56
+ let url: URL;
57
+ try { url = new URL(input); } catch { throw new RemoteCatalogError("url_invalid", "Catalog URL must be an absolute HTTPS URL"); }
58
+ if (url.username || url.password) throw new RemoteCatalogError("url_invalid", "Catalog URL must not contain credentials");
59
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) {
60
+ throw new RemoteCatalogError("insecure_http_refused", "Catalog URL requires HTTPS (HTTP is allowed only on loopback)");
61
+ }
62
+ if (url.pathname !== "/v1/catalog" || url.search || url.hash) {
63
+ throw new RemoteCatalogError("url_invalid", "Catalog URL must identify /v1/catalog without query or fragment");
64
+ }
65
+ return url;
66
+ }
67
+
68
+ function validateToken(token: string | undefined): string | undefined {
69
+ if (token === undefined) return undefined;
70
+ if (!token || token.length > 4096 || /[\r\n\0]/.test(token)) {
71
+ throw new RemoteCatalogError("credential_invalid", "Catalog authentication environment variable is invalid");
72
+ }
73
+ return token;
74
+ }
75
+
76
+ export function validateRemoteCatalogDocument(value: unknown): RemoteCatalogDocument {
77
+ const invalid = (message: string): never => { throw new RemoteCatalogError("catalog_invalid", message); };
78
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalid("Remote catalog must be a JSON object");
79
+ const document = value as Record<string, unknown>;
80
+ const rawModels = document.models;
81
+ if (!Array.isArray(rawModels) || rawModels.length === 0 || rawModels.length > MAX_MODELS) {
82
+ invalid("Remote catalog models must be a non-empty bounded array");
83
+ }
84
+ const models = rawModels as unknown[];
85
+ const slugs = new Set<string>();
86
+ for (const row of models) {
87
+ if (!row || typeof row !== "object" || Array.isArray(row) || Object.getPrototypeOf(row) !== Object.prototype) {
88
+ invalid("Remote catalog model rows must be plain objects");
89
+ }
90
+ const model = row as Record<string, unknown>;
91
+ const rawSlug = model.slug;
92
+ if (typeof rawSlug !== "string") invalid("Remote catalog contains an invalid model slug");
93
+ const slug = rawSlug as string;
94
+ if (slug !== slug.trim() || !slug
95
+ || new TextEncoder().encode(slug).byteLength > MAX_SLUG_BYTES || /[\x00-\x1f\x7f]/.test(slug)) {
96
+ invalid("Remote catalog contains an invalid model slug");
97
+ }
98
+ if (slugs.has(slug)) invalid("Remote catalog contains duplicate model slugs");
99
+ slugs.add(slug);
100
+ if (Object.hasOwn(model, "input_modalities")) {
101
+ const modalities = model.input_modalities;
102
+ if (!Array.isArray(modalities) || modalities.length === 0
103
+ || modalities.some(item => typeof item !== "string" || !ALLOWED_MODALITIES.has(item))) {
104
+ invalid("Remote catalog contains unsupported input modalities");
105
+ }
106
+ }
107
+ }
108
+ return document as RemoteCatalogDocument;
109
+ }
110
+
111
+ function safeTimeout(value: number | undefined): number {
112
+ return typeof value === "number" && Number.isFinite(value) && value > 0
113
+ ? Math.min(Math.floor(value), 120_000) : DEFAULT_TIMEOUT_MS;
114
+ }
115
+
116
+ export async function fetchRemoteCatalog(
117
+ input: string,
118
+ options: Pick<PullRemoteCatalogOptions, "token" | "timeoutMs" | "maxBytes" | "fetchImpl"> = {},
119
+ ): Promise<{ document: RemoteCatalogDocument; content: string }> {
120
+ const url = validateRemoteCatalogUrl(input);
121
+ const token = validateToken(options.token);
122
+ const headers = new Headers({ Accept: "application/json" });
123
+ if (token !== undefined) headers.set("Authorization", `Bearer ${token}`);
124
+ let response: Response;
125
+ try {
126
+ response = await (options.fetchImpl ?? fetch)(url, {
127
+ method: "GET", headers, redirect: "manual", signal: AbortSignal.timeout(safeTimeout(options.timeoutMs)),
128
+ });
129
+ } catch {
130
+ throw new RemoteCatalogError("request_failed", "Remote catalog request did not complete");
131
+ }
132
+ if (response.status >= 300 && response.status < 400) {
133
+ try { await response.body?.cancel(); } catch { /* best effort */ }
134
+ throw new RemoteCatalogError("redirect_refused", "Remote catalog redirect was refused", response.status);
135
+ }
136
+ if (!response.ok) {
137
+ try { await response.body?.cancel(); } catch { /* best effort */ }
138
+ throw new RemoteCatalogError("http_error", `Remote catalog request failed with HTTP ${response.status}`, response.status);
139
+ }
140
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
141
+ if (contentType !== "application/json" && contentType?.endsWith("+json") !== true) {
142
+ try { await response.body?.cancel(); } catch { /* best effort */ }
143
+ throw new RemoteCatalogError("body_invalid", "Remote catalog response was not JSON");
144
+ }
145
+ const maxBytes = options.maxBytes ?? MAX_REMOTE_CATALOG_BYTES;
146
+ const declaredRaw = response.headers.get("content-length");
147
+ if (declaredRaw !== null) {
148
+ const declared = Number(declaredRaw);
149
+ if (!Number.isSafeInteger(declared) || declared < 0 || declared > maxBytes) {
150
+ try { await response.body?.cancel(); } catch { /* best effort */ }
151
+ throw new RemoteCatalogError("body_too_large", "Remote catalog exceeded the allowed size");
152
+ }
153
+ }
154
+ let bytes: Uint8Array;
155
+ try {
156
+ const bounded = await readBoundedResponseBytes(response, { maxBytes, inactivityTimeoutMs: safeTimeout(options.timeoutMs) });
157
+ if (bounded.oversized) throw new RemoteCatalogError("body_too_large", "Remote catalog exceeded the allowed size");
158
+ bytes = bounded.bytes;
159
+ } catch (error) {
160
+ if (error instanceof RemoteCatalogError) throw error;
161
+ throw new RemoteCatalogError("request_failed", "Remote catalog download did not complete");
162
+ }
163
+ let text: string;
164
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
165
+ catch { throw new RemoteCatalogError("body_invalid", "Remote catalog was not valid UTF-8"); }
166
+ let parsed: unknown;
167
+ try { parsed = JSON.parse(text); }
168
+ catch { throw new RemoteCatalogError("body_invalid", "Remote catalog was not valid JSON"); }
169
+ const document = validateRemoteCatalogDocument(parsed);
170
+ return { document, content: `${JSON.stringify(document, null, 2)}\n` };
171
+ }
172
+
173
+ function mapSerializationFailure<T>(outcome: CatalogSerializationOutcome<T>): never {
174
+ if (outcome.kind === "completed") throw new RemoteCatalogError("write_failed", "Remote catalog installation failed");
175
+ const code = outcome.reason === "busy" ? "lock_busy" : outcome.reason === "database" ? "lock_database" : "unsafe_path";
176
+ throw new RemoteCatalogError(code, `Remote catalog installation unavailable (${outcome.reason})`);
177
+ }
178
+
179
+ /**
180
+ * Put the catalog file back the way this pull found it.
181
+ *
182
+ * `replaceActiveCodexCatalog` is an atomic FILE write; the serialization permit rolls back the
183
+ * SQLite transaction and nothing on disk. Without this, a cache rebuild that fails after the
184
+ * catalog was replaced leaves a new catalog paired with a stale `models_cache.json` — the exact
185
+ * split the last-known-good guarantee exists to prevent — while the caller is told the pull
186
+ * failed and wrote nothing.
187
+ */
188
+ function restorePreviousCatalog(
189
+ permit: CatalogWritePermit,
190
+ codexHome: string,
191
+ catalogPath: string,
192
+ previous: Buffer | null,
193
+ ): void {
194
+ if (previous) {
195
+ replaceActiveCodexCatalog(permit, codexHome, { path: catalogPath, content: previous.toString("utf8") });
196
+ return;
197
+ }
198
+ // There was no catalog before this pull, so last-known-good is its absence.
199
+ rmSync(catalogPath, { force: true });
200
+ resetCodexAppServerCatalogStateCache();
201
+ }
202
+
203
+ export async function pullRemoteCatalog(input: string, options: PullRemoteCatalogOptions = {}): Promise<PullRemoteCatalogResult> {
204
+ // Network acquisition and fail-closed validation intentionally happen before K.
205
+ const fetched = await fetchRemoteCatalog(input, options);
206
+ const codexHome = options.codexHome ?? getCodexHome();
207
+ const catalogPath = readCodexCatalogPathForHome(codexHome);
208
+ const current = existsSync(catalogPath) ? readFileSync(catalogPath) : null;
209
+ const candidate = Buffer.from(fetched.content, "utf8");
210
+ if (current?.equals(candidate)) {
211
+ return { status: "unchanged", catalogWritten: false, cacheSynced: false, codexHome, catalogPath, modelCount: fetched.document.models.length };
212
+ }
213
+ const outcome = withCatalogWriteSerialization(codexHome, permit => {
214
+ // Re-check under K: another writer may have installed these bytes while the request was in flight.
215
+ const lockedCurrent = existsSync(catalogPath) ? readFileSync(catalogPath) : null;
216
+ if (lockedCurrent?.equals(candidate)) return { catalogWritten: false, cacheSynced: false };
217
+ replaceActiveCodexCatalog(permit, codexHome, { path: catalogPath, content: fetched.content });
218
+ const cacheSynced = invalidateCodexModelsCacheWithPermit(permit, codexHome, { allowWhenDesiredDisabled: true });
219
+ if (!cacheSynced) {
220
+ restorePreviousCatalog(permit, codexHome, catalogPath, lockedCurrent);
221
+ throw new RemoteCatalogError("write_failed", "Remote catalog cache synchronization failed");
222
+ }
223
+ return { catalogWritten: true, cacheSynced: true };
224
+ });
225
+ if (outcome.kind !== "completed") return mapSerializationFailure(outcome);
226
+ return {
227
+ status: outcome.value.catalogWritten ? "updated" : "unchanged",
228
+ ...outcome.value,
229
+ codexHome,
230
+ catalogPath,
231
+ modelCount: fetched.document.models.length,
232
+ };
233
+ }