@bitkyc08/opencodex 2.65.0 → 2.66.0-preview.20260925

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 (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-BVjA0T6O.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-BCFtiWwD.js} +1 -1
  5. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  6. package/gui/dist/assets/index-vO4dgfiU.js +86 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-C4FiNaxk.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -1,16 +1,20 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
2
3
  import type { Server } from "bun";
3
4
  import { loadConfig } from "../config";
4
5
  import { removePid, removeRuntimePort, writePid, writeRuntimePort } from "../config/process-state";
5
6
  import { installCrashGuards } from "../lib/crash-guard";
6
7
  import { selfLaunchArgv } from "../lib/self-launch-argv";
7
8
  import { loadServiceTokenFromFile, serviceApiTokenFingerprint } from "../lib/service-secrets";
8
- import { findAvailablePort } from "../server/ports";
9
+ import { findAvailablePort, PortUnavailableError } from "../server/ports";
10
+ import { createClientLinkSupervisor, type ClientLinkSupervisor } from "./link-tunnel";
11
+ import { clientLinkStatePath } from "./link-state";
9
12
  import { startMachineListener } from "./machine-listener";
10
- import { readClientConnectionState } from "./state";
13
+ import { isLinkConnection, readClientConnectionState } from "./state";
11
14
 
12
15
  let activeServer: Server<unknown> | null = null;
13
16
  let activePort: number | null = null;
17
+ let activeSupervisor: ClientLinkSupervisor | null = null;
14
18
  let recycleScheduled = false;
15
19
 
16
20
  function cleanup(): void {
@@ -45,56 +49,89 @@ export function scheduleStandaloneRecycle(disconnectedTokenFingerprint: string):
45
49
  if (recycleScheduled) return;
46
50
  recycleScheduled = true;
47
51
  const timer = setTimeout(() => {
48
- const port = activePort;
49
- try { activeServer?.stop(true); } catch { /* best effort */ }
50
- cleanup();
51
- // Recycling back to standalone after `ocx disconnect` must actually bring a standalone
52
- // proxy back, under either launch shape.
53
- //
54
- // Unsupervised: spawn the replacement ourselves and exit 0.
55
- //
56
- // Supervised (`OCX_SERVICE=1`): do NOT spawn — the supervisor owns the process, and a
57
- // second copy would fight it for the port. But exit 0 does not work either: the real
58
- // supervisor configs are failure-only (systemd `Restart=on-failure`, WinSW
59
- // `<onfailure action="restart"/>`, the Task Scheduler ERRORLEVEL loop), so a clean exit
60
- // reads as "the service finished" and nothing restarts. The client stayed down until the
61
- // operator noticed. Exit 1 is what those configs are watching for, and it is the same
62
- // policy the dashboard recycle already uses (src/server/management/system-restart.ts).
63
- //
64
- // launchd's KeepAlive restarts on any exit, so it is correct under both branches.
65
- if (process.env.OCX_SERVICE === "1") {
66
- process.exit(1);
67
- }
68
- if (port) {
69
- const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(port)]), {
70
- detached: true,
71
- stdio: "ignore",
72
- windowsHide: true,
73
- env: standaloneRecycleEnv(process.env, disconnectedTokenFingerprint),
74
- });
75
- child.unref();
76
- }
77
- process.exit(0);
52
+ void recycleStandalone(disconnectedTokenFingerprint);
78
53
  }, 50);
79
54
  if (typeof timer === "object" && "unref" in timer) timer.unref();
80
55
  }
81
56
 
57
+ async function recycleStandalone(disconnectedTokenFingerprint: string): Promise<void> {
58
+ const port = activePort;
59
+ try {
60
+ await activeSupervisor?.stop();
61
+ } catch (error) {
62
+ console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`);
63
+ }
64
+ activeSupervisor = null;
65
+ try {
66
+ activeServer?.stop(true);
67
+ } catch (error) {
68
+ console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`);
69
+ }
70
+ cleanup();
71
+ // Recycling back to standalone after `ocx disconnect` must actually bring a standalone
72
+ // proxy back, under either launch shape.
73
+ //
74
+ // Unsupervised: spawn the replacement ourselves and exit 0.
75
+ //
76
+ // Supervised (`OCX_SERVICE=1`): do NOT spawn — the supervisor owns the process, and a
77
+ // second copy would fight it for the port. But exit 0 does not work either: the real
78
+ // supervisor configs are failure-only (systemd `Restart=on-failure`, WinSW
79
+ // `<onfailure action="restart"/>`, the Task Scheduler ERRORLEVEL loop), so a clean exit
80
+ // reads as "the service finished" and nothing restarts. The client stayed down until the
81
+ // operator noticed. Exit 1 is what those configs are watching for, and it is the same
82
+ // policy the dashboard recycle already uses (src/server/management/system-restart.ts).
83
+ //
84
+ // launchd's KeepAlive restarts on any exit, so it is correct under both branches.
85
+ if (process.env.OCX_SERVICE === "1") {
86
+ process.exit(1);
87
+ }
88
+ if (port) {
89
+ const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(port)]), {
90
+ detached: true,
91
+ stdio: "ignore",
92
+ windowsHide: true,
93
+ env: standaloneRecycleEnv(process.env, disconnectedTokenFingerprint),
94
+ });
95
+ child.unref();
96
+ }
97
+ process.exit(0);
98
+ }
99
+
82
100
  export async function startClientRuntime(
83
101
  options: { port?: number; block?: boolean } = {},
84
102
  ): Promise<void> {
85
103
  const state = readClientConnectionState();
86
104
  if (state.kind !== "connected") throw new Error(`client runtime refused: client state is ${state.kind}`);
87
105
  const config = loadConfig();
88
- const preferred = options.port ?? config.port ?? 10100;
89
- const port = await findAvailablePort(preferred, "127.0.0.1", {
90
- preferRetryMs: options.port === undefined ? 750 : 5_000,
91
- preferRetryIntervalMs: 50,
92
- allowEphemeralFallback: options.port === undefined,
93
- });
106
+ const linkMode = isLinkConnection(state.value);
107
+ if (linkMode && (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535)) {
108
+ throw new Error("link mode requires a valid local config port");
109
+ }
110
+ const preferred = linkMode ? config.port : options.port ?? config.port ?? 10100;
111
+ let port: number;
112
+ try {
113
+ port = await findAvailablePort(preferred, "127.0.0.1", {
114
+ preferRetryMs: options.port === undefined ? 750 : 5_000,
115
+ preferRetryIntervalMs: 50,
116
+ allowEphemeralFallback: linkMode ? false : options.port === undefined,
117
+ });
118
+ } catch (error) {
119
+ if (linkMode && error instanceof PortUnavailableError) {
120
+ throw new Error(`link mode needs port ${config.port}; free it or change port`, { cause: error });
121
+ }
122
+ throw error;
123
+ }
94
124
  const server = startMachineListener(port, { state: state.value });
95
125
  const boundPort = server.port ?? port;
96
126
  activeServer = server;
97
127
  activePort = boundPort;
128
+ const supervisor = linkMode && existsSync(clientLinkStatePath())
129
+ ? createClientLinkSupervisor({
130
+ onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint),
131
+ })
132
+ : null;
133
+ activeSupervisor = supervisor;
134
+ supervisor?.start();
98
135
  installCrashGuards();
99
136
  writePid(process.pid);
100
137
  writeRuntimePort({ pid: process.pid, port: boundPort, hostname: "127.0.0.1" });
@@ -103,10 +140,22 @@ export async function startClientRuntime(
103
140
  const shutdown = () => {
104
141
  if (shuttingDown) return;
105
142
  shuttingDown = true;
106
- try { server.stop(true); } finally {
107
- cleanup();
108
- process.exit(0);
109
- }
143
+ void (async () => {
144
+ try {
145
+ await supervisor?.stop();
146
+ } catch (error) {
147
+ console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`);
148
+ }
149
+ activeSupervisor = null;
150
+ try {
151
+ server.stop(true);
152
+ } catch (error) {
153
+ console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`);
154
+ } finally {
155
+ cleanup();
156
+ process.exit(0);
157
+ }
158
+ })();
110
159
  };
111
160
  process.on("SIGINT", shutdown);
112
161
  process.on("SIGTERM", shutdown);
@@ -26,6 +26,10 @@ export type ClientConnectionState =
26
26
  | { kind: "invalid"; reason: string }
27
27
  | { kind: "mismatched"; reason: string };
28
28
 
29
+ export function isLinkConnection(c: OcxClientConnectionConfig | undefined): boolean {
30
+ return c?.transport === "link";
31
+ }
32
+
29
33
  const pendingConnectPath = (): string => join(getConfigDir(), "client-connect-pending");
30
34
 
31
35
  /** Validate pending ownership; an optional fingerprint restricts it to that exact key. */
@@ -783,6 +783,10 @@ export function mergeCatalogEntriesFromObservedState({
783
783
  }
784
784
  const slug = String(entry.slug);
785
785
  if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue;
786
+ // The builder no longer copies a template's comp_hash onto routed rows (#5796), but a row
787
+ // kept from disk may still carry one. Custom rows, Codex-forward aliases included, never
788
+ // reach this loop: they are rebuilt from config.
789
+ entry.comp_hash = "opencodex";
786
790
  const featuredRank = featuredRankOf(slug);
787
791
  entry.priority = featuredRank !== undefined
788
792
  ? featuredRank * priorityStride
@@ -144,6 +144,11 @@ export function deriveEntry(
144
144
  delete e.context_window;
145
145
  delete e.max_context_window;
146
146
  delete e.auto_compact_token_limit;
147
+ // Nor its comp_hash (#5796). Codex compacts a thread whenever the recorded value
148
+ // changes, and the template is whichever native row a rebuild found first, so an
149
+ // inherited value moves with rebuild order. Left unset, normalization gives every
150
+ // routed row the same "opencodex" marker.
151
+ delete e.comp_hash;
147
152
  }
148
153
  if (typeof e.base_instructions === "string") {
149
154
  // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy
@@ -36,6 +36,7 @@ import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"
36
36
  import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
37
37
  import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
38
38
  import { NATIVE_GPT6_ASTRA_MODEL } from "./native-models";
39
+ import { recordOwnedConfigPath } from "../../lib/config-ownership";
39
40
 
40
41
  export function legacyCatalogBackupPath(): string {
41
42
  return join(getConfigDir(), "catalog-backup.json");
@@ -969,14 +970,17 @@ export function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean {
969
970
  }
970
971
 
971
972
  export function writePristineCatalogBackup(backupPath: string, catalogPath: string, catalog: RawCatalog): void {
973
+ // An existing name is not evidence of ownership; keep pre-ledger/user backups unclaimed.
972
974
  if (existsSync(backupPath)) return;
973
975
  const onDisk = readCatalog(catalogPath);
974
976
  if (onDisk && !catalogHasRoutedEntries(onDisk)) {
975
977
  copyFileSync(catalogPath, backupPath);
978
+ recordOwnedConfigPath(getConfigDir(), backupPath);
976
979
  return;
977
980
  }
978
981
  if (!catalogHasRoutedEntries(catalog)) {
979
982
  atomicWriteFile(backupPath, JSON.stringify(catalog, null, 2) + "\n");
983
+ recordOwnedConfigPath(getConfigDir(), backupPath);
980
984
  }
981
985
  }
982
986
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { loadConfig, websocketsEnabled } from "../../config";
3
+ import { initializeConfigOwnership } from "../../lib/config-ownership";
4
+ import { getConfigDir, loadConfig, websocketsEnabled } from "../../config";
4
5
  import { shouldSyncCodexOnStart } from "../desired-state";
5
6
  import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration";
6
7
  import { getCodexHome } from "../paths";
@@ -289,6 +290,8 @@ function writeRetainedCatalogSync({
289
290
  // (later syncs would otherwise overwrite it with featured-modified priorities).
290
291
  const pristine = pristineCatalogBytes(read);
291
292
  if (pristine !== null) {
293
+ // Initialize metadata while the root is empty; never pre-claim the hashed path.
294
+ initializeConfigOwnership(getConfigDir());
292
295
  publishHashedCodexCatalogBackup(permit, owningCodexHome, {
293
296
  path: catalogBackupPathFor(catalogPath),
294
297
  content: pristine,
@@ -1,5 +1,6 @@
1
1
  import type { OcxConfig } from "../types";
2
2
  import { shouldSyncCodexOnStart } from "./desired-state";
3
+ import { tomlString } from "./paths";
3
4
  import {
4
5
  isEffectiveCodexClientCompaction,
5
6
  isEffectiveCodexDesktopAuthless,
@@ -11,7 +12,7 @@ export type CodexDesktopSwitchInertReason =
11
12
 
12
13
  export interface CodexDesktopSwitchState {
13
14
  stored: boolean;
14
- effective: boolean;
15
+ effective: boolean | null;
15
16
  inertReason?: CodexDesktopSwitchInertReason;
16
17
  }
17
18
 
@@ -19,6 +20,8 @@ export type CodexDesktopSwitchApplyReason =
19
20
  | "not_requested"
20
21
  | "proxy_not_running"
21
22
  | "integration_disabled"
23
+ | "external_provider"
24
+ | "ownership_undetermined"
22
25
  | "write_lock_busy"
23
26
  | "injection_refused";
24
27
 
@@ -35,7 +38,7 @@ export interface CodexDesktopSwitchReport {
35
38
  codexDesktopAuthless: CodexDesktopSwitchState;
36
39
  codexClientCompaction: CodexDesktopSwitchState;
37
40
  apply: CodexDesktopSwitchApply;
38
- authSource: { presentsCodexAccount: boolean; summary: string };
41
+ authSource: { presentsCodexAccount: boolean | null; summary: string };
39
42
  }
40
43
 
41
44
  type DesktopSwitchConfig = Pick<
@@ -50,9 +53,10 @@ type DesktopSwitchConfig = Pick<
50
53
 
51
54
  function describeSwitch(
52
55
  stored: boolean,
53
- effective: boolean,
56
+ effective: boolean | null,
54
57
  config: Pick<OcxConfig, "runtimeRole">,
55
58
  ): CodexDesktopSwitchState {
59
+ if (effective === null) return { stored, effective };
56
60
  if (!stored || effective) return { stored, effective };
57
61
  return {
58
62
  stored,
@@ -68,15 +72,31 @@ export function describeCodexDesktopSwitches(
68
72
  apply: CodexDesktopSwitchApply,
69
73
  ): CodexDesktopSwitchReport {
70
74
  const authlessStored = config.codexDesktopAuthless === true;
71
- const authlessEffective = isEffectiveCodexDesktopAuthless(config);
75
+ const externallyOwned = !apply.applied && apply.reason === "external_provider";
76
+ const ownershipUndetermined = !apply.applied && apply.reason === "ownership_undetermined";
77
+ // An unreadable config.toml leaves ownership undetermined, so the local effective values are
78
+ // withheld exactly like the externally owned case: reporting them would present OpenCodex's
79
+ // stored-versus-computed state as live while the file may belong to another provider.
80
+ const effectiveWithheld = externallyOwned || ownershipUndetermined;
81
+ const authlessEffective = effectiveWithheld ? null : isEffectiveCodexDesktopAuthless(config);
72
82
  const compactionStored = config.codexClientCompaction === true;
73
- const compactionEffective = isEffectiveCodexClientCompaction(config);
83
+ const compactionEffective = effectiveWithheld ? null : isEffectiveCodexClientCompaction(config);
74
84
 
75
85
  return {
76
86
  codexDesktopAuthless: describeSwitch(authlessStored, authlessEffective, config),
77
87
  codexClientCompaction: describeSwitch(compactionStored, compactionEffective, config),
78
88
  apply,
79
- authSource: authlessEffective
89
+ authSource: externallyOwned
90
+ ? {
91
+ presentsCodexAccount: null,
92
+ summary: "An external model provider owns Codex sign-in behavior; its account requirement was not changed.",
93
+ }
94
+ : ownershipUndetermined
95
+ ? {
96
+ presentsCodexAccount: null,
97
+ summary: "Whether the Codex app requires its own account sign-in is undetermined; config.toml ownership could not be read.",
98
+ }
99
+ : authlessEffective
80
100
  ? {
81
101
  presentsCodexAccount: false,
82
102
  summary: "The Codex app will not require its own account sign-in.",
@@ -88,6 +108,53 @@ export function describeCodexDesktopSwitches(
88
108
  };
89
109
  }
90
110
 
111
+ /**
112
+ * The apply record for a report that attempted no rewrite. `not_requested` alone would have
113
+ * the report claiming OpenCodex's stored-versus-effective state as live, so the read path
114
+ * consults the same ownership predicate the injector does and reports external ownership
115
+ * instead — a settings GET and a switch-free PUT then agree with an attempted apply.
116
+ */
117
+ export async function observedCodexDesktopSwitchApply(): Promise<CodexDesktopSwitchApply> {
118
+ // Same lazy boundary as applyCodexConfigInjection: the ownership predicate lives in the
119
+ // injection graph, which the settings read path must not pull in at module scope.
120
+ const { currentExternalCodexModelProvider } = await import("./inject/config-toml");
121
+ let provider: string | null;
122
+ try {
123
+ provider = currentExternalCodexModelProvider();
124
+ } catch (error) {
125
+ // A present-but-unreadable config.toml (permissions, deletion racing existsSync)
126
+ // must not take down the whole settings report. The undetermined reason keeps the
127
+ // reporting contract honest: effective values and the sign-in answer stay null instead
128
+ // of presenting local state a foreign provider may still control.
129
+ return {
130
+ applied: false,
131
+ reason: "ownership_undetermined",
132
+ retryable: true,
133
+ detail: `config.toml ownership could not be determined: ${error instanceof Error ? error.message : String(error)}`,
134
+ };
135
+ }
136
+ if (!provider) return { applied: false, reason: "not_requested", retryable: false };
137
+ return {
138
+ applied: false,
139
+ reason: "external_provider",
140
+ retryable: false,
141
+ detail: `config.toml selects the external model_provider ${tomlString(provider)}.`,
142
+ };
143
+ }
144
+
145
+ // The apply gates skip the injector entirely, so they run the same ownership read the
146
+ // observed path does — a disabled integration or an absent runtime must not make a
147
+ // switch PUT report local state the external provider still controls. An undetermined
148
+ // read is kept for the same reason: replacing it with the gate's reason would drop the
149
+ // "ownership could not be determined" explanation the locked save still owes.
150
+ async function observedOwnershipApply(): Promise<CodexDesktopSwitchApply | null> {
151
+ const ownership = await observedCodexDesktopSwitchApply();
152
+ return !ownership.applied
153
+ && (ownership.reason === "external_provider" || ownership.reason === "ownership_undetermined")
154
+ ? ownership
155
+ : null;
156
+ }
157
+
91
158
  /**
92
159
  * Re-run the Codex config injection so a setting that lives in `~/.codex/config.toml` follows the
93
160
  * stored config NOW rather than at the next `ocx sync`.
@@ -100,13 +167,15 @@ export async function applyCodexConfigInjection(
100
167
  config: OcxConfig,
101
168
  ): Promise<CodexDesktopSwitchApply> {
102
169
  if (!shouldSyncCodexOnStart(config)) {
103
- return { applied: false, reason: "integration_disabled", retryable: false };
170
+ return (await observedOwnershipApply())
171
+ ?? { applied: false, reason: "integration_disabled", retryable: false };
104
172
  }
105
173
 
106
174
  const { readRuntimePort } = await import("../config/process-state");
107
175
  const runtime = readRuntimePort(process.pid);
108
176
  if (!runtime) {
109
- return { applied: false, reason: "proxy_not_running", retryable: true };
177
+ return (await observedOwnershipApply())
178
+ ?? { applied: false, reason: "proxy_not_running", retryable: true };
110
179
  }
111
180
 
112
181
  try {
@@ -123,6 +192,14 @@ export async function applyCodexConfigInjection(
123
192
  detail: result.message,
124
193
  };
125
194
  }
195
+ if (result.success && result.configApplied === false) {
196
+ return {
197
+ applied: false,
198
+ reason: "external_provider",
199
+ retryable: false,
200
+ detail: result.message,
201
+ };
202
+ }
126
203
  if (result.success) {
127
204
  // history_paginated_requires_native_writer stands down only the legacy relabel;
128
205
  // apply still writes the routing and catalog half for paginated Codex homes.
@@ -143,6 +220,9 @@ export async function applyCodexConfigInjection(
143
220
  detail: result.message,
144
221
  };
145
222
  } catch (error) {
223
+ // The injector may fail its ownership read after the initial apply gates passed.
224
+ const ownership = await observedOwnershipApply();
225
+ if (ownership) return ownership;
146
226
  return {
147
227
  applied: false,
148
228
  reason: "injection_refused",
@@ -102,6 +102,19 @@ export type CodexInjectionPlan =
102
102
  }
103
103
  | CodexInjectionPlanOk;
104
104
 
105
+ function websocketsForRoutingTarget(
106
+ config: OcxConfig | undefined,
107
+ routingTarget: CodexRoutingTarget,
108
+ ): boolean {
109
+ // A link client reaches the hub through an HTTP-only tunnel. Its local Codex target is
110
+ // deliberately distinct from the tunnel origin, so force the injected websocket setting off
111
+ // even when the operator enabled the global websocket option. Hub mode can also use a local
112
+ // HTTP target with admission, so the explicit discriminator is required here.
113
+ return (routingTarget as CodexRoutingTarget & { link?: boolean }).link === true
114
+ ? false
115
+ : websocketsEnabled(config ?? {});
116
+ }
117
+
105
118
  export function deriveCodexInjectionPlan(
106
119
  source: string,
107
120
  ctx: CodexInjectionPlanContext,
@@ -240,7 +253,7 @@ export function deriveCodexInjectionPlan(
240
253
  content =
241
254
  content.trimEnd() +
242
255
  "\n" +
243
- buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName);
256
+ buildProviderTableBlockForTarget(routingTarget, websocketsForRoutingTarget(config, routingTarget), config?.codexProviderDisplayName);
244
257
  // 3) Keep existing `openai`-tagged threads reaching the proxy (see above). Ownership rules
245
258
  // are the Design B ones: a user's own root line is never replaced.
246
259
  if (keepRootOverrideAlongsideTable) {
@@ -299,7 +312,7 @@ export function deriveCodexInjectionPlan(
299
312
  const profileContent = buildProfileFileForTarget(
300
313
  routingTarget,
301
314
  catalogPath,
302
- websocketsEnabled(config ?? {}),
315
+ websocketsForRoutingTarget(config, routingTarget),
303
316
  config?.fastMode,
304
317
  config?.codexProviderDisplayName,
305
318
  );
@@ -358,7 +371,7 @@ export function deriveCodexInjectionPlan(
358
371
  */
359
372
  if (hadOcxProviderTableOnDisk && !providerTableMode) {
360
373
  content = applyEol(
361
- content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName),
374
+ content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsForRoutingTarget(config, routingTarget), config?.codexProviderDisplayName),
362
375
  eol,
363
376
  );
364
377
  }
@@ -133,6 +133,8 @@ function runClientWriteGuard(guard: InjectCodexOptions["beforeClientWrite"]): vo
133
133
  export interface CodexInjectResult {
134
134
  success: boolean;
135
135
  message: string;
136
+ /** False when injection intentionally preserves configuration owned by another provider. */
137
+ configApplied?: false;
136
138
  /**
137
139
  * Structured read-only history preflight refusal; never parsed from display text.
138
140
  *
@@ -240,6 +242,7 @@ async function injectCodexConfigImpl(
240
242
  : undefined;
241
243
  return {
242
244
  success: true,
245
+ configApplied: false,
243
246
  ...(nativeSubagentDefaultsWarning
244
247
  ? { nativeSubagentDefaultsWarning }
245
248
  : {}),
@@ -5,6 +5,7 @@ import {
5
5
  AtomicWriteResidualTempError,
6
6
  AtomicWriteSecretResidualError,
7
7
  atomicWriteFile,
8
+ getConfigDir,
8
9
  resolveWriteTarget,
9
10
  type AtomicWriteIO,
10
11
  } from "../../config";
@@ -17,6 +18,7 @@ import {
17
18
  hardenSecretPath,
18
19
  } from "../../lib/windows-secret-acl";
19
20
  import { resetCodexAppServerCatalogStateCache } from "../app-server-processes";
21
+ import { recordOwnedConfigPath } from "../../lib/config-ownership";
20
22
 
21
23
  export interface PreparedCatalogFileWrite {
22
24
  readonly path: string;
@@ -169,6 +171,7 @@ function scrubAndRemoveUnpublishedTemp(
169
171
  function publishCatalogBackup(
170
172
  prepared: PreparedCatalogFileWrite,
171
173
  suppliedIo?: CatalogBackupWriteIO,
174
+ onPublished?: () => void,
172
175
  ): CatalogBackupPublication {
173
176
  const io = suppliedIo ?? defaultBackupWriteIO(prepared.path);
174
177
  const target = io.resolveTarget(prepared.path);
@@ -187,7 +190,12 @@ function publishCatalogBackup(
187
190
  throw error;
188
191
  }
189
192
 
190
- removePublishedTemp(tempPath, io);
193
+ try {
194
+ // Publication already succeeded; cleanup failure must not erase that ownership.
195
+ onPublished?.();
196
+ } finally {
197
+ removePublishedTemp(tempPath, io);
198
+ }
191
199
  return "written";
192
200
  }
193
201
 
@@ -211,7 +219,10 @@ export function publishHashedCodexCatalogBackup(
211
219
  io?: CatalogBackupWriteIO,
212
220
  ): CatalogBackupPublication {
213
221
  assertCatalogWritePermit(permit, owningCodexHome);
214
- return publishCatalogBackup(prepared, io);
222
+ return publishCatalogBackup(prepared, io, io ? undefined : () => {
223
+ // Called only after a new no-replace publication, never for preserved winners.
224
+ recordOwnedConfigPath(getConfigDir(), prepared.path);
225
+ });
215
226
  }
216
227
 
217
228
  /** Atomically publish the legacy immutable backup without clobbering. */
@@ -215,11 +215,11 @@ export function coolComboTarget(
215
215
  code?: string | null;
216
216
  message?: string;
217
217
  },
218
- ): void {
218
+ ): boolean {
219
219
  const now = options?.now ?? Date.now();
220
220
  const writerGeneration = options?.writerGeneration ?? captureConfigGeneration();
221
221
  const ownerKey = `${comboId}::${targetKey(target)}`;
222
- if (writerGeneration < lastReconciledGeneration && !liveComboTargets.has(ownerKey)) return;
222
+ if (writerGeneration < lastReconciledGeneration && !liveComboTargets.has(ownerKey)) return false;
223
223
  // A server-provided Retry-After is authoritative, including an immediate `0` directive.
224
224
  // A quota reset is the next-most-specific signal (#3256); configured and default cooldowns
225
225
  // are only fallbacks when upstream supplied neither usable value.
@@ -240,6 +240,7 @@ export function coolComboTarget(
240
240
  cooldownUntil: now + (serverDelayMs ?? Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS)),
241
241
  });
242
242
  sweepExpiredOnWrite(now);
243
+ return true;
243
244
  }
244
245
 
245
246
  export function earliestComboCooldown(
@@ -56,3 +56,15 @@ export {
56
56
  resetComboEffortWarningStateForTests,
57
57
  } from "./request";
58
58
  export { earliestQuotaResetAt, quotaResetRemainingMs } from "./reset-window";
59
+ export {
60
+ buildJevRouteQuestion,
61
+ buildJevState,
62
+ JEV_API_URL,
63
+ JEV_MODEL,
64
+ JEV_PROVIDER_ID,
65
+ parseJevDecision,
66
+ resolveJevDecision,
67
+ type JevCandidate,
68
+ type JevDecision,
69
+ type ResolveJevDecisionOptions,
70
+ } from "./jev";