@bitkyc08/opencodex 2.12.0 → 2.13.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 (173) hide show
  1. package/README.md +1 -1
  2. package/gui/dist/assets/index-BHldBl6_.js +76 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/anthropic-output-schema.ts +137 -0
  6. package/src/adapters/anthropic.ts +15 -0
  7. package/src/adapters/google-antigravity-replay.ts +269 -5
  8. package/src/adapters/openai-chat.ts +25 -18
  9. package/src/adapters/openai-responses.ts +63 -17
  10. package/src/bridge.ts +11 -5
  11. package/src/claude/inbound.ts +24 -3
  12. package/src/cli/account-api.ts +2 -0
  13. package/src/cli/account-extended.ts +262 -1
  14. package/src/cli/account.ts +3 -1
  15. package/src/cli/dispatch.ts +532 -0
  16. package/src/cli/doctor.ts +26 -21
  17. package/src/cli/help.ts +9 -278
  18. package/src/cli/index.ts +45 -506
  19. package/src/cli/lab.ts +171 -65
  20. package/src/cli/registry.ts +384 -0
  21. package/src/cli/root.ts +86 -0
  22. package/src/cli/status.ts +9 -2
  23. package/src/cli/v2.ts +37 -2
  24. package/src/codex/account-label.ts +14 -1
  25. package/src/codex/account-store.ts +12 -1
  26. package/src/codex/admission.ts +2 -1
  27. package/src/codex/auth-api.ts +3 -2
  28. package/src/codex/catalog/metadata.ts +6 -4
  29. package/src/codex/catalog/parsing.ts +31 -7
  30. package/src/codex/catalog/provider-fetch.ts +2 -1
  31. package/src/codex/catalog/sync.ts +65 -11
  32. package/src/codex/features.ts +437 -31
  33. package/src/codex/history-job.ts +6 -11
  34. package/src/codex/history-provider.ts +10 -9
  35. package/src/codex/inject.ts +14 -1
  36. package/src/codex/native-profile-startup.ts +49 -3
  37. package/src/codex/native-residue.ts +14 -2
  38. package/src/codex/paths.ts +75 -1
  39. package/src/codex/reset-credit-recovery.ts +1044 -0
  40. package/src/codex/shim.ts +876 -16
  41. package/src/codex/sync.ts +27 -2
  42. package/src/config.ts +148 -10
  43. package/src/generated/compatibility-version.json +2572 -0
  44. package/src/images/loop.ts +1 -1
  45. package/src/lab/artifacts/sanitize.ts +3 -3
  46. package/src/lab/automation/budgets.ts +78 -0
  47. package/src/lab/automation/config-persistence.ts +256 -0
  48. package/src/lab/automation/constants.ts +39 -0
  49. package/src/lab/automation/cooldown.ts +103 -0
  50. package/src/lab/automation/dispatch.ts +211 -0
  51. package/src/lab/automation/index.ts +13 -0
  52. package/src/lab/automation/orchestrator.ts +480 -0
  53. package/src/lab/automation/persistence.ts +512 -0
  54. package/src/lab/automation/planner.ts +371 -0
  55. package/src/lab/automation/policy.ts +136 -0
  56. package/src/lab/automation/queue.ts +191 -0
  57. package/src/lab/automation/recovery.ts +24 -0
  58. package/src/lab/automation/route-context.ts +21 -0
  59. package/src/lab/automation/run-key.ts +44 -0
  60. package/src/lab/automation/runs-query.ts +34 -0
  61. package/src/lab/automation/types.ts +160 -0
  62. package/src/lab/conformance/types.ts +23 -0
  63. package/src/lab/events/types.ts +2 -1
  64. package/src/lab/events/validate.ts +2 -1
  65. package/src/lab/fabric/constants.ts +40 -0
  66. package/src/lab/fabric/executor.ts +492 -0
  67. package/src/lab/fabric/index.ts +80 -0
  68. package/src/lab/fabric/manifest.ts +222 -0
  69. package/src/lab/fabric/observe.ts +454 -0
  70. package/src/lab/fabric/patch.ts +79 -0
  71. package/src/lab/fabric/producer-child.ts +139 -0
  72. package/src/lab/fabric/producer-isolate.ts +276 -0
  73. package/src/lab/fabric/producer-protocol.ts +61 -0
  74. package/src/lab/fabric/scratch.ts +439 -0
  75. package/src/lab/fabric/subject.ts +106 -0
  76. package/src/lab/fabric/types.ts +134 -0
  77. package/src/lab/fabric/verifier.ts +98 -0
  78. package/src/lab/index.ts +2 -0
  79. package/src/lab/ledger/store.ts +173 -0
  80. package/src/lab/live/executor.ts +29 -4
  81. package/src/lab/live/transport.ts +3 -1
  82. package/src/lab/observe/from-conformance.ts +12 -57
  83. package/src/lab/observe/from-live.ts +9 -1
  84. package/src/lab/paths.ts +74 -9
  85. package/src/lab/projection/schema.ts +14 -1
  86. package/src/lab/projection/verdicts.ts +89 -53
  87. package/src/lab/projection/verification.ts +83 -3
  88. package/src/lab/query/catalog.ts +34 -5
  89. package/src/lab/query/freshness.ts +53 -0
  90. package/src/lab/query/index.ts +11 -0
  91. package/src/lab/query/latest-observation.ts +59 -0
  92. package/src/lab/query/passive-production.ts +159 -0
  93. package/src/lab/subject/installation-salt.ts +24 -4
  94. package/src/lab/subject/protocol-subject.ts +80 -0
  95. package/src/lab/subject/route-subject.ts +2 -1
  96. package/src/lib/fabric-task-execution-authority.ts +7 -0
  97. package/src/lib/fabric-task-host.ts +29 -0
  98. package/src/lib/lab-live-route-production.ts +130 -0
  99. package/src/lib/local-management-capability.ts +100 -0
  100. package/src/lib/server-resource-ownership.ts +71 -0
  101. package/src/lib/windows-elevation.ts +8 -1
  102. package/src/lib/windows-user-principal.ts +62 -2
  103. package/src/lib/winsw.ts +8 -2
  104. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  105. package/src/oauth/account-import/index.ts +15 -0
  106. package/src/oauth/account-import/parser.ts +83 -0
  107. package/src/oauth/account-import/registry.ts +18 -0
  108. package/src/oauth/account-import/service.ts +75 -0
  109. package/src/oauth/account-import/types.ts +91 -0
  110. package/src/oauth/anthropic.ts +12 -1
  111. package/src/oauth/callback-server.ts +8 -2
  112. package/src/oauth/chatgpt.ts +12 -1
  113. package/src/oauth/google-antigravity.ts +27 -0
  114. package/src/oauth/health.ts +12 -48
  115. package/src/oauth/index.ts +122 -0
  116. package/src/oauth/kimi.ts +16 -2
  117. package/src/oauth/nous.ts +749 -0
  118. package/src/oauth/store.ts +56 -3
  119. package/src/providers/codex-capacity.ts +1 -0
  120. package/src/providers/registry.ts +161 -16
  121. package/src/responses/custom-tool-compat.ts +182 -0
  122. package/src/responses/parser.ts +22 -0
  123. package/src/responses/reasoning-replay-cache.ts +168 -11
  124. package/src/responses/schema.ts +1 -1
  125. package/src/router.ts +5 -20
  126. package/src/routing/capability.ts +9 -3
  127. package/src/routing/compatibility/assemble.ts +159 -0
  128. package/src/routing/compatibility/behavior.ts +224 -0
  129. package/src/routing/compatibility/catalog.ts +99 -0
  130. package/src/routing/compatibility/endpoint.ts +52 -0
  131. package/src/routing/compatibility/policy.ts +181 -0
  132. package/src/routing/compatibility/reader.ts +110 -0
  133. package/src/routing/compatibility/subject.ts +191 -0
  134. package/src/routing/compatibility/types.ts +64 -0
  135. package/src/routing/compatibility/version.ts +104 -0
  136. package/src/routing/evaluator.ts +27 -32
  137. package/src/routing/profile.ts +118 -4
  138. package/src/routing/trace.ts +82 -28
  139. package/src/server/auth-cors.ts +7 -0
  140. package/src/server/background-lifecycle.ts +20 -7
  141. package/src/server/chat-completions.ts +2 -2
  142. package/src/server/claude-messages.ts +6 -3
  143. package/src/server/direct-local-http.ts +343 -0
  144. package/src/server/index.ts +80 -18
  145. package/src/server/lifecycle.ts +12 -4
  146. package/src/server/local-management-read-client.ts +90 -0
  147. package/src/server/management/agent-settings-routes.ts +27 -3
  148. package/src/server/management/lab-automation-routes.ts +206 -0
  149. package/src/server/management/lab-routes.ts +13 -0
  150. package/src/server/management/logs-usage-routes.ts +1 -0
  151. package/src/server/management/oauth-account-routes.ts +57 -1
  152. package/src/server/management/provider-routes.ts +16 -0
  153. package/src/server/management/routing-profile-routes.ts +12 -24
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/management-auth.ts +68 -3
  156. package/src/server/proxy-liveness.ts +3 -2
  157. package/src/server/request-decompress.ts +9 -3
  158. package/src/server/request-log.ts +53 -4
  159. package/src/server/responses/collaboration.ts +93 -17
  160. package/src/server/responses/compact.ts +3 -0
  161. package/src/server/responses/core.ts +202 -15
  162. package/src/server/responses/policy-fallback.ts +1 -0
  163. package/src/server/responses-custom-tool-repair.ts +280 -0
  164. package/src/server/responses-terminal-repair.ts +342 -0
  165. package/src/server/startup-action-control.ts +8 -1
  166. package/src/service.ts +278 -12
  167. package/src/types.ts +49 -3
  168. package/src/usage/expected-prices.ts +30 -0
  169. package/src/usage/log.ts +30 -4
  170. package/src/usage/summary.ts +153 -2
  171. package/src/web-search/loop.ts +1 -1
  172. package/gui/dist/assets/index-Cw1_mxO8.js +0 -70
  173. package/src/cli/internal-dispatch.ts +0 -20
package/src/codex/shim.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { delimiter, dirname, extname, join, posix } from "node:path";
2
+ import { spawnSync } from "node:child_process";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, delimiter, dirname, extname, join, posix } from "node:path";
3
5
  import {
4
6
  chmodSync,
5
7
  closeSync,
@@ -7,11 +9,13 @@ import {
7
9
  fstatSync,
8
10
  lstatSync,
9
11
  mkdirSync,
12
+ mkdtempSync,
10
13
  openSync,
11
14
  readFileSync,
12
15
  readdirSync,
13
16
  readSync,
14
17
  renameSync,
18
+ rmSync,
15
19
  rmdirSync,
16
20
  statSync,
17
21
  type Stats,
@@ -29,10 +33,173 @@ import { isWslRuntime, wslAutomountRoot } from "./home";
29
33
  import { truncateRetainedUtf8 } from "../lib/admission";
30
34
 
31
35
  const SHIM_MARKER = "opencodex codex autostart shim";
36
+ const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 2";
32
37
  const CODEX_SHIM_PROBE_BYTES = 16 * 1024;
33
38
  export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100;
34
39
  export const CODEX_SHIM_STATE_MAX_BYTES = 1024 * 1024;
35
40
  const CODEX_SHIM_RESTORE_LOCK_STALE_MS = 30_000;
41
+ const CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS = 5_000;
42
+ const CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS = 1_000;
43
+ const CODEX_SHIM_REENTRY_EXIT_CODE = 126;
44
+ const CODEX_SHIM_REENTRY_DIAGNOSTIC = "opencodex: saved Codex launcher resolved back to the autostart shim; run ocx codex-shim uninstall and reinstall Codex before enabling codexAutoStart.";
45
+ const CODEX_SHIM_INSTALL_PROBE_SCRIPT = `
46
+ const { spawn } = require("node:child_process");
47
+ const { readFileSync, writeFileSync } = require("node:fs");
48
+ const [markerPath, reentryPath, groupPath, stderrPath, launcherShellPath, wrapperPath, timeoutRaw, stderrLimitRaw, stderrDrainRaw, observationRaw] = process.argv.slice(1);
49
+ const timeoutMs = Number.parseInt(timeoutRaw, 10);
50
+ const stderrLimit = Number.parseInt(stderrLimitRaw, 10);
51
+ const stderrDrainMs = Number.parseInt(stderrDrainRaw, 10);
52
+ const observationMs = Number.parseInt(observationRaw, 10);
53
+ const probeStartedAt = Date.now();
54
+ const stderrChunks = [];
55
+ let stderrBytes = 0;
56
+ let launcher;
57
+ let probeLease;
58
+ let timer;
59
+ let stderrDrainTimer;
60
+ let observationTimer;
61
+ let reentryPollTimer;
62
+ let marker = "";
63
+ let finished = false;
64
+
65
+ function writeExclusive(path, value) {
66
+ writeFileSync(path, value, { flag: "wx", mode: 0o600 });
67
+ }
68
+
69
+ function appendStderr(value) {
70
+ if (stderrBytes >= stderrLimit) return;
71
+ const bytes = Buffer.from(value);
72
+ const retained = bytes.subarray(0, stderrLimit - stderrBytes);
73
+ stderrChunks.push(retained);
74
+ stderrBytes += retained.byteLength;
75
+ }
76
+
77
+ function groupAlive() {
78
+ if (!launcher || !launcher.pid) return false;
79
+ try {
80
+ process.kill(-launcher.pid, 0);
81
+ return true;
82
+ } catch (error) {
83
+ return error && error.code !== "ESRCH";
84
+ }
85
+ }
86
+
87
+ function killGroup() {
88
+ if (!launcher || !launcher.pid) return;
89
+ try { process.kill(-launcher.pid, "SIGKILL"); } catch (error) {
90
+ if (!error || error.code !== "ESRCH") appendStderr(String(error));
91
+ }
92
+ }
93
+
94
+ function setMarker(value) {
95
+ if (marker) return;
96
+ marker = value;
97
+ try { writeExclusive(markerPath, value + "\\n"); } catch (error) { appendStderr(String(error)); }
98
+ }
99
+
100
+ function reentryDetected() {
101
+ try { return readFileSync(reentryPath, "utf8").trim() === "recursive"; } catch { return false; }
102
+ }
103
+
104
+ function checkReentry() {
105
+ if (finished || !reentryDetected()) return;
106
+ setMarker("recursive");
107
+ killGroup();
108
+ finish(126);
109
+ }
110
+
111
+ function finish(status) {
112
+ if (finished) return;
113
+ finished = true;
114
+ if (timer) clearTimeout(timer);
115
+ if (stderrDrainTimer) clearTimeout(stderrDrainTimer);
116
+ if (observationTimer) clearTimeout(observationTimer);
117
+ if (reentryPollTimer) clearInterval(reentryPollTimer);
118
+ if (!marker && reentryDetected()) setMarker("recursive");
119
+ if (!marker && groupAlive()) {
120
+ setMarker("descendants");
121
+ killGroup();
122
+ }
123
+ try { writeExclusive(stderrPath, Buffer.concat(stderrChunks)); } catch { /* parent fails closed */ }
124
+ process.exit(marker === "timeout" ? 124 : marker === "descendants" ? 125 : marker === "recursive" ? 126 : status);
125
+ }
126
+
127
+ function finishAfterStderr(status) {
128
+ if (finished) return;
129
+ if (timer) {
130
+ clearTimeout(timer);
131
+ timer = undefined;
132
+ }
133
+ if (!launcher || !launcher.stderr || !probeLease) {
134
+ finish(status);
135
+ return;
136
+ }
137
+ let stderrEnded = launcher.stderr.readableEnded;
138
+ let leaseEnded = probeLease.readableEnded;
139
+ let observationElapsed = false;
140
+ const finishWhenReady = () => {
141
+ if (stderrEnded && leaseEnded && observationElapsed) finish(status);
142
+ };
143
+ launcher.stderr.once("end", () => {
144
+ stderrEnded = true;
145
+ finishWhenReady();
146
+ });
147
+ probeLease.once("end", () => {
148
+ leaseEnded = true;
149
+ finishWhenReady();
150
+ });
151
+ stderrDrainTimer = setTimeout(() => {
152
+ stderrEnded = true;
153
+ if (!marker && groupAlive()) {
154
+ setMarker("descendants");
155
+ killGroup();
156
+ finish(125);
157
+ return;
158
+ }
159
+ finishWhenReady();
160
+ }, stderrDrainMs);
161
+ const remainingObservationMs = Math.max(0, observationMs - (Date.now() - probeStartedAt));
162
+ observationTimer = setTimeout(() => {
163
+ observationElapsed = true;
164
+ if (!leaseEnded) {
165
+ setMarker(groupAlive() ? "descendants" : "timeout");
166
+ killGroup();
167
+ finish(marker === "descendants" ? 125 : 124);
168
+ return;
169
+ }
170
+ finishWhenReady();
171
+ }, remainingObservationMs);
172
+ finishWhenReady();
173
+ }
174
+
175
+ try {
176
+ launcher = spawn(launcherShellPath, [wrapperPath, "--version"], {
177
+ detached: true,
178
+ env: process.env,
179
+ stdio: ["ignore", "ignore", "pipe", "pipe"],
180
+ });
181
+ if (!launcher.pid) throw new Error("Codex shim probe launcher has no pid");
182
+ probeLease = launcher.stdio[3];
183
+ if (!probeLease) throw new Error("Codex shim probe launcher has no descendant lease pipe");
184
+ writeExclusive(groupPath, String(launcher.pid) + "\\n");
185
+ launcher.stderr.on("data", appendStderr);
186
+ reentryPollTimer = setInterval(checkReentry, 10);
187
+ launcher.once("error", error => {
188
+ appendStderr(String(error));
189
+ finishAfterStderr(127);
190
+ });
191
+ launcher.once("exit", code => finishAfterStderr(Number.isInteger(code) ? code : 127));
192
+ timer = setTimeout(() => {
193
+ setMarker("timeout");
194
+ killGroup();
195
+ finish(124);
196
+ }, timeoutMs);
197
+ } catch (error) {
198
+ appendStderr(String(error));
199
+ killGroup();
200
+ finish(127);
201
+ }
202
+ `;
36
203
  const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
37
204
  let lastShimDiscoveryError: string | null = null;
38
205
  /** Last human-readable reason discovery returned null (exposed for doctor/tests). */
@@ -148,6 +315,7 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean {
148
315
  try {
149
316
  const content = readFileSync(path, "utf8");
150
317
  if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false;
318
+ if (platform !== "win32" && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false;
151
319
  if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false;
152
320
  return true;
153
321
  } catch {
@@ -204,6 +372,12 @@ function sameFingerprint(
204
372
  : false);
205
373
  }
206
374
 
375
+ function sameFingerprintAfterRename(left: ShimPathFingerprint, right: ShimPathFingerprint): boolean {
376
+ // rename changes the outer directory entry ctime on macOS; every other field,
377
+ // including a symlink target fingerprint, must remain identical.
378
+ return sameFingerprint({ ...left, ctimeMs: 0 }, { ...right, ctimeMs: 0 });
379
+ }
380
+
207
381
  function stableShimPathProbe(path: string): StableShimPathProbe | null {
208
382
  const before = statFingerprint(path, false);
209
383
  if (!before) return null;
@@ -239,6 +413,10 @@ function isHealthyShimProbe(probe: StableShimPathProbe, platform: NodeJS.Platfor
239
413
  return platform === "win32" || (mode & 0o111) !== 0;
240
414
  }
241
415
 
416
+ function isCurrentUnixShimProbe(probe: StableShimPathProbe): boolean {
417
+ return probe.prefix.includes(UNIX_SHIM_REVISION_MARKER);
418
+ }
419
+
242
420
  function hasUsableBackingPath(file: ShimFileState): boolean {
243
421
  return [existsSync(file.backupPath) ? file.backupPath : undefined, file.realPath]
244
422
  .some(path => {
@@ -383,6 +561,40 @@ export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPa
383
561
  const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.join("|");
384
562
  return `#!/usr/bin/env sh
385
563
  # ${SHIM_MARKER}
564
+ # ${UNIX_SHIM_REVISION_MARKER}
565
+ if [ "\${OCX_SHIM_PROBE:-}" = "1" ]; then
566
+ if [ "\${OCX_SHIM_PROBE_ACTIVE:-}" = "1" ]; then
567
+ if [ -n "\${OCX_SHIM_PROBE_REENTRY_PATH:-}" ]; then
568
+ (umask 077; printf '%s\n' recursive > "$OCX_SHIM_PROBE_REENTRY_PATH") 2>/dev/null || true
569
+ fi
570
+ printf '%s\n' ${shQuote(CODEX_SHIM_REENTRY_DIAGNOSTIC)} >&2
571
+ exit ${CODEX_SHIM_REENTRY_EXIT_CODE}
572
+ fi
573
+ OCX_SHIM_PROBE_ACTIVE=1
574
+ export OCX_SHIM_PROBE_ACTIVE
575
+ fi
576
+ if [ "\${OCX_SHIM_ACTIVE_PID:-}" = "$$" ]; then
577
+ printf '%s\n' ${shQuote(CODEX_SHIM_REENTRY_DIAGNOSTIC)} >&2
578
+ exit ${CODEX_SHIM_REENTRY_EXIT_CODE}
579
+ fi
580
+ case "\${OCX_SHIM_ACTIVE_DEPTH:-0}" in
581
+ 0)
582
+ OCX_SHIM_ACTIVE_DEPTH=1
583
+ ;;
584
+ 1)
585
+ OCX_SHIM_ACTIVE_DEPTH=2
586
+ ;;
587
+ *)
588
+ printf '%s\n' ${shQuote(CODEX_SHIM_REENTRY_DIAGNOSTIC)} >&2
589
+ exit ${CODEX_SHIM_REENTRY_EXIT_CODE}
590
+ ;;
591
+ esac
592
+ # Dynamic launchers such as mise exec -- codex may resolve the command name
593
+ # back to this wrapper. An exec chain keeps the same PID. A legitimate nested
594
+ # Codex invocation may enter once with a new PID; repeated child-process
595
+ # redispatch reaches depth 2 and is rejected before it can form an infinite chain.
596
+ OCX_SHIM_ACTIVE_PID=$$
597
+ export OCX_SHIM_ACTIVE_PID OCX_SHIM_ACTIVE_DEPTH
386
598
  if [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then
387
599
  OPENCODEX_API_AUTH_TOKEN="$(cat ${shQuote(tokenFile)})"
388
600
  export OPENCODEX_API_AUTH_TOKEN
@@ -426,6 +638,219 @@ exec ${shQuote(realCodexPath)} "$@"
426
638
  `;
427
639
  }
428
640
 
641
+ type UnixShimProbeResult = "cleanup" | "descendants" | "failed" | "recursive" | "timeout" | null;
642
+
643
+ let codexShimProbeHookForTests: (() => void) | null = null;
644
+ let codexShimProbeShellForTests: string | null = null;
645
+ let codexShimGuardedWriteHookForTests: (() => void) | null = null;
646
+ let codexShimFreshWriteHookForTests: (() => void) | null = null;
647
+ let codexShimProbeObservationMs = CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS;
648
+
649
+ /** Narrow deterministic seam for transaction rollback tests. */
650
+ export function setCodexShimProbeHookForTests(hook: (() => void) | null): void {
651
+ codexShimProbeHookForTests = hook;
652
+ }
653
+
654
+ /** Selects a POSIX shell only for cross-shell probe regression tests. */
655
+ export function setCodexShimProbeShellForTests(path: string | null): void {
656
+ codexShimProbeShellForTests = path;
657
+ }
658
+
659
+ /** Shortens the successful-launcher observation window only for focused tests. */
660
+ export function setCodexShimProbeObservationMsForTests(value: number | null): void {
661
+ codexShimProbeObservationMs = value ?? CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS;
662
+ }
663
+
664
+ /** Narrow deterministic seam for guarded partial-write rollback tests. */
665
+ export function setCodexShimGuardedWriteHookForTests(hook: (() => void) | null): void {
666
+ codexShimGuardedWriteHookForTests = hook;
667
+ }
668
+
669
+ /** Narrow deterministic seam for fresh-install partial-write rollback tests. */
670
+ export function setCodexShimFreshWriteHookForTests(hook: (() => void) | null): void {
671
+ codexShimFreshWriteHookForTests = hook;
672
+ }
673
+
674
+ function readProbeMetadata(path: string, maxBytes: number): string | null {
675
+ try {
676
+ if (!existsSync(path)) return "";
677
+ const stat = lstatSync(path);
678
+ if (!stat.isFile() || stat.size > maxBytes) return null;
679
+ return readFileSync(path, "utf8").trim();
680
+ } catch {
681
+ return null;
682
+ }
683
+ }
684
+
685
+ function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult {
686
+ if (process.platform === "win32") return null;
687
+ const probeDir = mkdtempSync(join(tmpdir(), "opencodex-shim-probe-"));
688
+ const markerPath = join(probeDir, "result");
689
+ const reentryPath = join(probeDir, "reentry");
690
+ const groupPath = join(probeDir, "group");
691
+ const stderrPath = join(probeDir, "stderr");
692
+ const env: NodeJS.ProcessEnv = {
693
+ ...process.env,
694
+ OCX_SHIM_BYPASS: "1",
695
+ OCX_SHIM_PROBE: "1",
696
+ OCX_SHIM_PROBE_REENTRY_PATH: reentryPath,
697
+ };
698
+ delete env.OCX_SHIM_ACTIVE_PID;
699
+ delete env.OCX_SHIM_ACTIVE_DEPTH;
700
+ delete env.OCX_SHIM_PROBE_ACTIVE;
701
+ let groupId = 0;
702
+ try {
703
+ chmodSync(probeDir, 0o700);
704
+ const result = spawnSync(process.execPath, [
705
+ "-e",
706
+ CODEX_SHIM_INSTALL_PROBE_SCRIPT,
707
+ markerPath,
708
+ reentryPath,
709
+ groupPath,
710
+ stderrPath,
711
+ codexShimProbeShellForTests ?? "/bin/sh",
712
+ wrapperPath,
713
+ String(CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS),
714
+ String(MAX_DIAGNOSTIC_VALUE_BYTES),
715
+ String(CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS),
716
+ String(codexShimProbeObservationMs),
717
+ ], {
718
+ encoding: "utf8",
719
+ env,
720
+ timeout: CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS + CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS,
721
+ killSignal: "SIGKILL",
722
+ });
723
+ const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
724
+ const marker = readProbeMetadata(markerPath, 64);
725
+ const reentryMarker = readProbeMetadata(reentryPath, 64);
726
+ const groupText = readProbeMetadata(groupPath, 64);
727
+ const launcherStderr = readProbeMetadata(stderrPath, MAX_DIAGNOSTIC_VALUE_BYTES);
728
+ groupId = groupText === null ? 0 : Number.parseInt(groupText, 10);
729
+ if (marker === null || reentryMarker === null || groupText === null || launcherStderr === null
730
+ || !Number.isInteger(groupId) || groupId <= 0) return "cleanup";
731
+ const groupSurvived = unixProcessGroupAlive(groupId);
732
+ if (timedOut || marker || reentryMarker || groupSurvived) {
733
+ try {
734
+ terminateUnixProcessGroup(groupId);
735
+ } catch {
736
+ return "cleanup";
737
+ }
738
+ }
739
+ if (result.error && !timedOut) return "cleanup";
740
+ if (timedOut || marker === "timeout") return "timeout";
741
+ if (marker === "recursive" || reentryMarker === "recursive") return "recursive";
742
+ if (reentryMarker !== "") return "cleanup";
743
+ if (marker === "descendants") return "descendants";
744
+ if (groupSurvived) return "descendants";
745
+ if (result.status === CODEX_SHIM_REENTRY_EXIT_CODE && launcherStderr.includes(CODEX_SHIM_REENTRY_DIAGNOSTIC)) {
746
+ return "recursive";
747
+ }
748
+ if (result.status !== 0) return "failed";
749
+ return null;
750
+ } catch {
751
+ if (Number.isInteger(groupId) && groupId > 0) {
752
+ try { terminateUnixProcessGroup(groupId); } catch { /* cleanup classification below */ }
753
+ }
754
+ return "cleanup";
755
+ } finally {
756
+ try { rmSync(probeDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
757
+ }
758
+ }
759
+
760
+ function probeUnixShimFiles(files: readonly ShimFileState[]): UnixShimProbeResult {
761
+ if (process.platform === "win32") return null;
762
+ codexShimProbeHookForTests?.();
763
+ return files
764
+ .filter(file => !file.preserveOnly)
765
+ .map(file => probeUnixShimInstall(file.wrapperPath))
766
+ .find(result => result !== null) ?? null;
767
+ }
768
+
769
+ function unixProcessGroupAlive(groupId: number): boolean {
770
+ try {
771
+ process.kill(-groupId, 0);
772
+ return true;
773
+ } catch (error) {
774
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
775
+ }
776
+ }
777
+
778
+ function terminateUnixProcessGroup(groupId: number): void {
779
+ try {
780
+ process.kill(-groupId, "SIGKILL");
781
+ } catch (error) {
782
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
783
+ }
784
+ const deadline = Date.now() + CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS;
785
+ while (Date.now() < deadline && unixProcessGroupAlive(groupId)) Bun.sleepSync(10);
786
+ if (unixProcessGroupAlive(groupId)) {
787
+ throw new Error(`Codex shim install probe process group ${groupId} did not terminate`);
788
+ }
789
+ }
790
+
791
+ interface FreshShimInstallJournalEntry {
792
+ target: ShimFileState;
793
+ movedOriginalFingerprint?: ShimPathFingerprint;
794
+ originalMovedToBackup: boolean;
795
+ writtenWrapperFingerprint?: ShimPathFingerprint;
796
+ wrapperWriteStarted: boolean;
797
+ /** dev/ino of the file our `writeShim()` created, recorded before anything can fail. */
798
+ writtenWrapperInode?: { dev: number; ino: number };
799
+ }
800
+
801
+ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry[]): void {
802
+ const errors: Error[] = [];
803
+ for (const entry of [...journal].reverse()) {
804
+ const target = entry.target;
805
+ let sourceOccupied = false;
806
+ let ownsWrapperNow = false;
807
+ try {
808
+ const wrapper = stableShimPathProbe(target.wrapperPath);
809
+ // Ownership is the inode our own write created. There is no marker-text
810
+ // fallback: the markers are public, so a concurrent updater's wrapper carries
811
+ // them too, and treating that as proof is how we would delete a file we never
812
+ // wrote. An in-place truncation of our file keeps the inode and is still ours
813
+ // to clean up; a replacement renamed over it has a different inode and is not.
814
+ const ownsWrapper = !target.preserveOnly
815
+ && entry.wrapperWriteStarted
816
+ && wrapper !== null
817
+ && wrapperInodeIsOurs(wrapper, entry.writtenWrapperInode, entry.writtenWrapperFingerprint);
818
+ ownsWrapperNow = ownsWrapper;
819
+ if (ownsWrapper) unlinkSync(target.wrapperPath);
820
+ else {
821
+ try {
822
+ lstatSync(target.originalPath);
823
+ sourceOccupied = true;
824
+ } catch (error) {
825
+ if (fileErrorCode(error) !== "ENOENT") sourceOccupied = true;
826
+ }
827
+ }
828
+ } catch (error) {
829
+ errors.push(error instanceof Error ? error : new Error(String(error)));
830
+ }
831
+ try {
832
+ if (entry.originalMovedToBackup && existsSync(target.backupPath)) {
833
+ const movedOriginal = stableShimPathProbe(target.backupPath);
834
+ if (!movedOriginal || !entry.movedOriginalFingerprint
835
+ || !sameFingerprint(movedOriginal.fingerprint, entry.movedOriginalFingerprint)) {
836
+ throw new Error("Codex shim fresh-install backup changed during rollback");
837
+ }
838
+ if (sourceOccupied) {
839
+ // Something else occupies the source path. Dropping the backup is correct
840
+ // only when that something is a file we own; when a concurrent updater
841
+ // owns it, this backup is the user's real launcher and deleting it would
842
+ // lose the command entirely. Keep it in that case — a stray
843
+ // `codex.opencodex-real` is recoverable, a deleted launcher is not.
844
+ if (ownsWrapperNow) unlinkSync(target.backupPath);
845
+ } else renameSync(target.backupPath, target.originalPath);
846
+ }
847
+ } catch (error) {
848
+ errors.push(error instanceof Error ? error : new Error(String(error)));
849
+ }
850
+ }
851
+ if (errors.length > 0) throw new AggregateError(errors, "Codex shim install validation rollback failed");
852
+ }
853
+
429
854
  function windowsBatchValue(value: string): string {
430
855
  return value
431
856
  .replace(/%/g, "%%")
@@ -626,7 +1051,16 @@ function gitBashPath(path: string): string {
626
1051
  return path.replace(/\\/g, "/");
627
1052
  }
628
1053
 
629
- function writeShim(wrapperPath: string, realCodexPath: string): void {
1054
+ /**
1055
+ * Write the wrapper and return the identity of the inode this call created, or
1056
+ * `undefined` where the platform still writes the destination in place.
1057
+ *
1058
+ * Callers must derive ownership from the returned identity rather than from a
1059
+ * later `stat` of `wrapperPath`: the shim markers are public, so a concurrent
1060
+ * updater's wrapper can carry them, and a replacement landing between the write
1061
+ * and the observation is otherwise indistinguishable from our own file.
1062
+ */
1063
+ function writeShim(wrapperPath: string, realCodexPath: string): { dev: number; ino: number } | undefined {
630
1064
  const { bun, bunRuntimeSource, cli } = cliEntry();
631
1065
  if (process.platform === "win32") {
632
1066
  const lower = wrapperPath.toLowerCase();
@@ -644,12 +1078,83 @@ function writeShim(wrapperPath: string, realCodexPath: string): void {
644
1078
  "utf8",
645
1079
  );
646
1080
  }
1081
+ return undefined;
647
1082
  } else {
648
- writeFileSync(wrapperPath, buildUnixCodexShim(realCodexPath, bun, cli, bunRuntimeSource), "utf8");
649
- chmodSync(wrapperPath, 0o755);
1083
+ // Stage the wrapper as its own inode and rename it into place, so ownership
1084
+ // comes from the write itself rather than from observing the path afterwards.
1085
+ // Writing the destination directly leaves a window in which a concurrent
1086
+ // updater can replace the file between our write and our fingerprint; we would
1087
+ // then adopt that replacement as ours and unlink it during rollback, deleting
1088
+ // an executable we never wrote.
1089
+ // Hidden and non-executable while staged, so a crash between the write and the
1090
+ // rename cannot leave an executable `codex*` artifact that a glob or a shell
1091
+ // completion would surface.
1092
+ const staged = join(dirname(wrapperPath), `.${basename(wrapperPath)}.opencodex-staging.${process.pid}.${randomUUID()}`);
1093
+ let renamed = false;
1094
+ try {
1095
+ // "wx" fails if the staging path somehow exists, so we never inherit a file.
1096
+ writeFileSync(staged, buildUnixCodexShim(realCodexPath, bun, cli, bunRuntimeSource), { encoding: "utf8", flag: "wx", mode: 0o600 });
1097
+ const stagedStat = lstatSync(staged);
1098
+ chmodSync(staged, 0o755);
1099
+ renameSync(staged, wrapperPath);
1100
+ renamed = true;
1101
+ // rename() preserves dev/ino and updates ctime, so identity is the inode
1102
+ // pair, captured from the file we created rather than from the destination.
1103
+ return { dev: stagedStat.dev, ino: stagedStat.ino };
1104
+ } finally {
1105
+ if (!renamed) {
1106
+ try { unlinkSync(staged); } catch { /* best-effort: nothing to clean up */ }
1107
+ }
1108
+ }
650
1109
  }
651
1110
  }
652
1111
 
1112
+ /**
1113
+ * The fingerprint to record as "we wrote this", or `undefined` when the file at
1114
+ * `wrapperPath` is not the inode `writeShim()` created. Returning `undefined`
1115
+ * makes every rollback path treat the file as someone else's and leave it alone.
1116
+ */
1117
+ function ownedWrapperFingerprint(
1118
+ wrapperPath: string,
1119
+ written: { dev: number; ino: number } | undefined,
1120
+ ): ShimPathFingerprint | undefined {
1121
+ const probe = stableShimPathProbe(wrapperPath);
1122
+ if (!probe) return undefined;
1123
+ // Platforms that still write in place (Windows) have no staged identity; fall
1124
+ // back to the marker check they have always used.
1125
+ if (!written) return probe.prefix.includes(SHIM_MARKER) ? probe.fingerprint : undefined;
1126
+ if (probe.fingerprint.dev !== written.dev || probe.fingerprint.ino !== written.ino) return undefined;
1127
+ return probe.fingerprint;
1128
+ }
1129
+
1130
+ /**
1131
+ * Whether the file now at the wrapper path is one this transaction may unlink.
1132
+ *
1133
+ * The inode our write created is the authority. It stays ours through an in-place
1134
+ * truncation — a partial write we must clean up — and stops being ours the moment
1135
+ * someone renames a different file over the path, which is exactly the concurrent
1136
+ * updater we must not delete. Where no inode was recorded (Windows writes the
1137
+ * destination directly), fall back to the exact fingerprint recorded at the time.
1138
+ */
1139
+ function wrapperInodeIsOurs(
1140
+ wrapper: StableShimPathProbe,
1141
+ written: { dev: number; ino: number } | undefined,
1142
+ recorded: ShimPathFingerprint | undefined,
1143
+ ): boolean {
1144
+ // A different inode is conclusive: someone renamed their own file over ours.
1145
+ if (written && (wrapper.fingerprint.dev !== written.dev || wrapper.fingerprint.ino !== written.ino)) {
1146
+ return false;
1147
+ }
1148
+ // Same inode is not sufficient on its own — an in-place truncation (shell `>`,
1149
+ // writeFileSync) keeps it while replacing the contents. When we recorded a full
1150
+ // fingerprint, require it to still match; that covers our own partial write,
1151
+ // whose fingerprint we record before anything can fail.
1152
+ if (recorded !== undefined) return sameFingerprint(wrapper.fingerprint, recorded);
1153
+ // No recorded fingerprint: only the inode identity we captured at write time can
1154
+ // speak for us, and there is nothing else to distinguish this file.
1155
+ return written !== undefined;
1156
+ }
1157
+
653
1158
  function stateFiles(state: ShimState): ShimFileState[] {
654
1159
  return state.wrappers?.length
655
1160
  ? state.wrappers
@@ -684,12 +1189,37 @@ function refreshShimFile(file: ShimFileState): boolean {
684
1189
  }
685
1190
  if (existsSync(file.wrapperPath) && !isShim(file.wrapperPath)) {
686
1191
  if (file.wrapperPath !== file.originalPath) return false;
687
- replaceOwnedBackup(file.wrapperPath, file.backupPath);
688
- writeShim(file.wrapperPath, file.realPath ?? file.backupPath);
689
- return true;
1192
+ const replacement = stableShimPathProbe(file.wrapperPath);
1193
+ if (!replacement) return false;
1194
+ return applyGuardedRefreshTransaction([{
1195
+ file,
1196
+ expectedReplacement: replacement.fingerprint,
1197
+ sourcePath: file.wrapperPath,
1198
+ }]);
690
1199
  }
691
1200
  if (!existsSync(file.wrapperPath) && existsSync(file.backupPath)) {
692
1201
  writeShim(file.wrapperPath, file.realPath ?? file.backupPath);
1202
+ const writtenWrapper = stableShimPathProbe(file.wrapperPath);
1203
+ if (!writtenWrapper || !writtenWrapper.prefix.includes(SHIM_MARKER)) {
1204
+ return false;
1205
+ }
1206
+ let unsafe: UnixShimProbeResult = null;
1207
+ let probeError: Error | null = null;
1208
+ try {
1209
+ unsafe = probeUnixShimFiles([file]);
1210
+ } catch (error) {
1211
+ probeError = error instanceof Error ? error : new Error(String(error));
1212
+ }
1213
+ const currentWrapper = stableShimPathProbe(file.wrapperPath);
1214
+ const wrapperChangedDuringProbe = !currentWrapper
1215
+ || !sameFingerprint(currentWrapper.fingerprint, writtenWrapper.fingerprint);
1216
+ if (unsafe !== null || probeError || wrapperChangedDuringProbe) {
1217
+ if (currentWrapper && sameFingerprint(currentWrapper.fingerprint, writtenWrapper.fingerprint)) {
1218
+ unlinkSync(file.wrapperPath);
1219
+ }
1220
+ if (probeError) throw probeError;
1221
+ return false;
1222
+ }
693
1223
  return true;
694
1224
  }
695
1225
  if (file.originalPath !== file.wrapperPath && existsSync(file.originalPath) && existsSync(file.wrapperPath) && isShim(file.wrapperPath)) {
@@ -709,7 +1239,9 @@ interface GuardedRefreshOperation {
709
1239
  interface GuardedRefreshJournalEntry {
710
1240
  operation: GuardedRefreshOperation;
711
1241
  stagedOldBackupPath?: string;
1242
+ movedReplacementFingerprint?: ShimPathFingerprint;
712
1243
  replacementMovedToBackup: boolean;
1244
+ writtenWrapperFingerprint?: ShimPathFingerprint;
713
1245
  wrapperWriteStarted: boolean;
714
1246
  }
715
1247
 
@@ -901,14 +1433,35 @@ function rollbackGuardedRefresh(journal: readonly GuardedRefreshJournalEntry[]):
901
1433
  }
902
1434
  };
903
1435
  for (const entry of [...journal].reverse()) {
1436
+ let sourceOccupied = false;
904
1437
  attempt(() => {
905
- if (entry.wrapperWriteStarted && existsSync(entry.operation.file.wrapperPath)) {
1438
+ const wrapper = stableShimPathProbe(entry.operation.file.wrapperPath);
1439
+ // No marker-text fallback: the markers are public, so a concurrent updater's
1440
+ // wrapper carries them too. No recorded inode identity means not ours.
1441
+ const ownsWrapper = entry.wrapperWriteStarted
1442
+ && wrapper !== null
1443
+ && entry.writtenWrapperFingerprint !== undefined
1444
+ && sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint);
1445
+ if (ownsWrapper) {
906
1446
  unlinkSync(entry.operation.file.wrapperPath);
1447
+ } else {
1448
+ try {
1449
+ lstatSync(entry.operation.sourcePath);
1450
+ sourceOccupied = true;
1451
+ } catch (error) {
1452
+ if (fileErrorCode(error) !== "ENOENT") sourceOccupied = true;
1453
+ }
907
1454
  }
908
1455
  });
909
1456
  attempt(() => {
910
1457
  if (entry.replacementMovedToBackup && existsSync(entry.operation.file.backupPath)) {
911
- renameSync(entry.operation.file.backupPath, entry.operation.sourcePath);
1458
+ const movedReplacement = stableShimPathProbe(entry.operation.file.backupPath);
1459
+ if (!movedReplacement || !entry.movedReplacementFingerprint
1460
+ || !sameFingerprint(movedReplacement.fingerprint, entry.movedReplacementFingerprint)) {
1461
+ throw new Error("Codex shim guarded refresh backup changed during rollback");
1462
+ }
1463
+ if (sourceOccupied) unlinkSync(entry.operation.file.backupPath);
1464
+ else renameSync(entry.operation.file.backupPath, entry.operation.sourcePath);
912
1465
  }
913
1466
  });
914
1467
  attempt(() => {
@@ -928,6 +1481,8 @@ function applyGuardedRefreshTransaction(
928
1481
  const journal: GuardedRefreshJournalEntry[] = [];
929
1482
  let applyError: Error | null = null;
930
1483
  let fingerprintMismatch = false;
1484
+ let unsafeLauncher = false;
1485
+ let wrapperChangedDuringProbe = false;
931
1486
  const transactionId = `${process.pid}-${++guardedRefreshTransactionId}`;
932
1487
 
933
1488
  for (const [index, operation] of operations.entries()) {
@@ -951,15 +1506,43 @@ function applyGuardedRefreshTransaction(
951
1506
  }
952
1507
  renameSync(operation.sourcePath, operation.file.backupPath);
953
1508
  entry.replacementMovedToBackup = true;
1509
+ const movedReplacement = stableShimPathProbe(operation.file.backupPath);
1510
+ if (!movedReplacement) throw new Error("Codex shim guarded refresh could not fingerprint the staged launcher");
1511
+ entry.movedReplacementFingerprint = movedReplacement.fingerprint;
954
1512
  entry.wrapperWriteStarted = true;
955
- writeShim(operation.file.wrapperPath, operation.file.realPath ?? operation.file.backupPath);
1513
+ const writtenInode = writeShim(operation.file.wrapperPath, operation.file.realPath ?? operation.file.backupPath);
1514
+ // Claim our own partial write before the hook can fail (see fresh install).
1515
+ entry.writtenWrapperFingerprint = ownedWrapperFingerprint(operation.file.wrapperPath, writtenInode);
1516
+ codexShimGuardedWriteHookForTests?.();
1517
+ // Re-check: unset means a concurrent writer owns the path now, so rollback
1518
+ // must leave it alone.
1519
+ entry.writtenWrapperFingerprint = ownedWrapperFingerprint(operation.file.wrapperPath, writtenInode);
1520
+ if (!entry.writtenWrapperFingerprint && !writtenInode) {
1521
+ throw new Error("Codex shim guarded refresh could not fingerprint the generated wrapper");
1522
+ }
956
1523
  } catch (error) {
957
1524
  applyError = error instanceof Error ? error : new Error(String(error));
958
1525
  break;
959
1526
  }
960
1527
  }
961
1528
 
962
- if (!fingerprintMismatch && !applyError && commitState) {
1529
+ if (!fingerprintMismatch && !applyError) {
1530
+ try {
1531
+ unsafeLauncher = probeUnixShimFiles(operations.map(operation => operation.file)) !== null;
1532
+ } catch (error) {
1533
+ applyError = error instanceof Error ? error : new Error(String(error));
1534
+ }
1535
+ }
1536
+
1537
+ if (!fingerprintMismatch && !applyError && !unsafeLauncher) {
1538
+ wrapperChangedDuringProbe = journal.some(entry => {
1539
+ const wrapper = stableShimPathProbe(entry.operation.file.wrapperPath);
1540
+ return !wrapper || !entry.writtenWrapperFingerprint
1541
+ || !sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint);
1542
+ });
1543
+ }
1544
+
1545
+ if (!fingerprintMismatch && !applyError && !unsafeLauncher && !wrapperChangedDuringProbe && commitState) {
963
1546
  try {
964
1547
  commitState();
965
1548
  } catch (error) {
@@ -967,7 +1550,7 @@ function applyGuardedRefreshTransaction(
967
1550
  }
968
1551
  }
969
1552
 
970
- if (fingerprintMismatch || applyError) {
1553
+ if (fingerprintMismatch || applyError || unsafeLauncher || wrapperChangedDuringProbe) {
971
1554
  const rollbackErrors = rollbackGuardedRefresh(journal);
972
1555
  if (applyError || rollbackErrors.length > 0) {
973
1556
  throw new AggregateError(
@@ -990,10 +1573,186 @@ function applyGuardedRefreshTransaction(
990
1573
  return true;
991
1574
  }
992
1575
 
1576
+ interface ObsoleteUnixShimJournalEntry {
1577
+ file: ShimFileState;
1578
+ stagedWrapperPath: string;
1579
+ priorWrapperFingerprint: ShimPathFingerprint;
1580
+ backingFingerprint: ShimPathFingerprint;
1581
+ writtenWrapperFingerprint?: ShimPathFingerprint;
1582
+ wrapperWriteStarted: boolean;
1583
+ }
1584
+
1585
+ function rollbackObsoleteUnixShimRefresh(journal: readonly ObsoleteUnixShimJournalEntry[]): Error[] {
1586
+ const errors: Error[] = [];
1587
+ const attempt = (operation: () => void): void => {
1588
+ try {
1589
+ operation();
1590
+ } catch (error) {
1591
+ errors.push(error instanceof Error ? error : new Error(String(error)));
1592
+ }
1593
+ };
1594
+ for (const entry of [...journal].reverse()) {
1595
+ attempt(() => {
1596
+ const wrapper = stableShimPathProbe(entry.file.wrapperPath);
1597
+ const ownsWrapper = entry.wrapperWriteStarted
1598
+ && wrapper !== null
1599
+ && (entry.writtenWrapperFingerprint
1600
+ ? sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint)
1601
+ : wrapper.prefix.includes(UNIX_SHIM_REVISION_MARKER));
1602
+ if (ownsWrapper) unlinkSync(entry.file.wrapperPath);
1603
+ });
1604
+ attempt(() => {
1605
+ if (!existsSync(entry.stagedWrapperPath)) return;
1606
+ if (existsSync(entry.file.wrapperPath)) unlinkSync(entry.stagedWrapperPath);
1607
+ else renameSync(entry.stagedWrapperPath, entry.file.wrapperPath);
1608
+ });
1609
+ }
1610
+ return errors;
1611
+ }
1612
+
1613
+ type ObsoleteUnixShimRefreshResult =
1614
+ | { installed: true; message: string }
1615
+ | { installed: false; deferred: boolean; message: string };
1616
+
1617
+ function refreshObsoleteUnixShims(files: readonly ShimFileState[]): ObsoleteUnixShimRefreshResult {
1618
+ if (process.platform === "win32") {
1619
+ return { installed: false, deferred: false, message: "Codex autostart shim is already current." };
1620
+ }
1621
+ const candidates = files.filter(file => {
1622
+ if (file.preserveOnly || file.wrapperPath !== file.originalPath || !existsSync(file.wrapperPath)) return false;
1623
+ const probe = stableShimPathProbe(file.wrapperPath);
1624
+ return probe !== null && probe.prefix.includes(SHIM_MARKER) && !isCurrentUnixShimProbe(probe);
1625
+ });
1626
+ if (candidates.length === 0) {
1627
+ return { installed: false, deferred: true, message: "Codex autostart shim upgrade deferred because tracked launchers changed." };
1628
+ }
1629
+
1630
+ const journal: ObsoleteUnixShimJournalEntry[] = [];
1631
+ const transactionId = `${process.pid}-${randomUUID()}`;
1632
+ let applyError: Error | null = null;
1633
+ for (const [index, file] of candidates.entries()) {
1634
+ const wrapper = stableShimPathProbe(file.wrapperPath);
1635
+ const backing = stableShimPathProbe(file.backupPath);
1636
+ if (!wrapper || !wrapper.prefix.includes(SHIM_MARKER) || isCurrentUnixShimProbe(wrapper) || !backing) {
1637
+ applyError = new Error("Codex autostart shim upgrade inputs changed before regeneration");
1638
+ break;
1639
+ }
1640
+ const entry: ObsoleteUnixShimJournalEntry = {
1641
+ file,
1642
+ stagedWrapperPath: `${file.wrapperPath}.upgrade-${transactionId}-${index}`,
1643
+ priorWrapperFingerprint: wrapper.fingerprint,
1644
+ backingFingerprint: backing.fingerprint,
1645
+ wrapperWriteStarted: false,
1646
+ };
1647
+ journal.push(entry);
1648
+ try {
1649
+ renameSync(file.wrapperPath, entry.stagedWrapperPath);
1650
+ const stagedWrapper = stableShimPathProbe(entry.stagedWrapperPath);
1651
+ if (!stagedWrapper
1652
+ || !sameFingerprintAfterRename(stagedWrapper.fingerprint, entry.priorWrapperFingerprint)) {
1653
+ throw new Error("Codex autostart shim upgrade could not fingerprint the staged wrapper");
1654
+ }
1655
+ entry.wrapperWriteStarted = true;
1656
+ const writtenInode = writeShim(file.wrapperPath, file.realPath ?? file.backupPath);
1657
+ const writtenWrapper = stableShimPathProbe(file.wrapperPath);
1658
+ if (!writtenWrapper || !isCurrentUnixShimProbe(writtenWrapper)) {
1659
+ throw new Error("Codex autostart shim upgrade could not fingerprint the regenerated wrapper");
1660
+ }
1661
+ // Identity is the inode we created; a replacement that landed since the
1662
+ // rename leaves this unset so rollback treats the file as someone else's.
1663
+ entry.writtenWrapperFingerprint = ownedWrapperFingerprint(file.wrapperPath, writtenInode);
1664
+ } catch (error) {
1665
+ applyError = error instanceof Error ? error : new Error(String(error));
1666
+ break;
1667
+ }
1668
+ }
1669
+
1670
+ let unsafe: UnixShimProbeResult = null;
1671
+ let probeError: Error | null = null;
1672
+ if (!applyError) {
1673
+ try {
1674
+ unsafe = probeUnixShimFiles(candidates);
1675
+ } catch (error) {
1676
+ probeError = error instanceof Error ? error : new Error(String(error));
1677
+ }
1678
+ }
1679
+ const changedDuringProbe = !applyError && !probeError && journal.some(entry => {
1680
+ const wrapper = stableShimPathProbe(entry.file.wrapperPath);
1681
+ const backing = stableShimPathProbe(entry.file.backupPath);
1682
+ return !wrapper || !entry.writtenWrapperFingerprint
1683
+ || !sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint)
1684
+ || !backing || !sameFingerprint(backing.fingerprint, entry.backingFingerprint);
1685
+ });
1686
+
1687
+ if (applyError || probeError || changedDuringProbe) {
1688
+ const rollbackErrors = rollbackObsoleteUnixShimRefresh(journal);
1689
+ if (applyError || probeError || rollbackErrors.length > 0) {
1690
+ throw new AggregateError(
1691
+ [...(applyError ? [applyError] : []), ...(probeError ? [probeError] : []), ...rollbackErrors],
1692
+ "Codex autostart shim upgrade failed",
1693
+ );
1694
+ }
1695
+ return { installed: false, deferred: true, message: "Codex autostart shim upgrade deferred because tracked launchers changed." };
1696
+ }
1697
+
1698
+ if (unsafe) {
1699
+ const cleanupErrors: Error[] = [];
1700
+ for (const entry of [...journal].reverse()) {
1701
+ try {
1702
+ const wrapper = stableShimPathProbe(entry.file.wrapperPath);
1703
+ if (!wrapper || !entry.writtenWrapperFingerprint
1704
+ || !sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint)) {
1705
+ throw new Error("Codex autostart shim upgrade lost wrapper ownership before removal");
1706
+ }
1707
+ const backing = stableShimPathProbe(entry.file.backupPath);
1708
+ if (!backing || !sameFingerprint(backing.fingerprint, entry.backingFingerprint)) {
1709
+ throw new Error("Codex autostart shim upgrade backing launcher changed before restoration");
1710
+ }
1711
+ unlinkSync(entry.file.wrapperPath);
1712
+ renameSync(entry.file.backupPath, entry.file.originalPath);
1713
+ if (existsSync(entry.stagedWrapperPath)) unlinkSync(entry.stagedWrapperPath);
1714
+ } catch (error) {
1715
+ cleanupErrors.push(error instanceof Error ? error : new Error(String(error)));
1716
+ }
1717
+ }
1718
+ if (cleanupErrors.length > 0) {
1719
+ throw new AggregateError(cleanupErrors, "Codex autostart shim upgrade safety removal failed");
1720
+ }
1721
+ if (existsSync(statePath())) unlinkSync(statePath());
1722
+ return {
1723
+ installed: false,
1724
+ deferred: false,
1725
+ message: "Removed an obsolete Codex autostart shim because its saved launcher failed current validation. The original launcher was restored; reinstall Codex as a concrete executable before enabling codexAutoStart.",
1726
+ };
1727
+ }
1728
+
1729
+ const cleanupErrors: Error[] = [];
1730
+ for (const entry of journal) {
1731
+ try {
1732
+ if (existsSync(entry.stagedWrapperPath)) unlinkSync(entry.stagedWrapperPath);
1733
+ } catch (error) {
1734
+ cleanupErrors.push(error instanceof Error ? error : new Error(String(error)));
1735
+ }
1736
+ }
1737
+ if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "Codex autostart shim upgrade cleanup failed");
1738
+ return {
1739
+ installed: true,
1740
+ message: `Upgraded Codex autostart shim at ${candidates.map(file => file.wrapperPath).join(", ")} and validated the saved launcher.`,
1741
+ };
1742
+ }
1743
+
993
1744
  function installCodexShimInternal(options: InstallCodexShimInternalOptions): { installed: boolean; message: string } {
994
1745
  const existing = readState();
995
1746
  if (existing) {
996
1747
  const files = stateFiles(existing);
1748
+ if (!options.expectedReplacements && process.platform !== "win32") {
1749
+ const hasObsoleteShim = files.some(file => {
1750
+ if (file.preserveOnly) return false;
1751
+ const probe = stableShimPathProbe(file.wrapperPath);
1752
+ return probe !== null && probe.prefix.includes(SHIM_MARKER) && !isCurrentUnixShimProbe(probe);
1753
+ });
1754
+ if (hasObsoleteShim) return refreshObsoleteUnixShims(files);
1755
+ }
997
1756
  if (options.expectedReplacements) {
998
1757
  const operations = planGuardedRefreshTransaction(files, options.expectedReplacements);
999
1758
  if (!operations || operations.length === 0) {
@@ -1060,9 +1819,92 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
1060
1819
  for (const target of targets) {
1061
1820
  if (existsSync(target.backupPath)) return { installed: false, message: `Refusing to overwrite existing backup: ${target.backupPath}` };
1062
1821
  }
1822
+ const freshJournal: FreshShimInstallJournalEntry[] = [];
1823
+ let freshApplyError: Error | null = null;
1063
1824
  for (const target of targets) {
1064
- if (existsSync(target.originalPath)) renameSync(target.originalPath, target.backupPath);
1065
- if (!target.preserveOnly) writeShim(target.wrapperPath, target.realPath ?? target.backupPath);
1825
+ const entry: FreshShimInstallJournalEntry = {
1826
+ target,
1827
+ originalMovedToBackup: false,
1828
+ wrapperWriteStarted: false,
1829
+ };
1830
+ freshJournal.push(entry);
1831
+ try {
1832
+ if (existsSync(target.originalPath)) {
1833
+ renameSync(target.originalPath, target.backupPath);
1834
+ entry.originalMovedToBackup = true;
1835
+ if (process.platform !== "win32") {
1836
+ const movedOriginal = stableShimPathProbe(target.backupPath);
1837
+ if (!movedOriginal) throw new Error("Codex shim fresh install could not fingerprint the staged launcher");
1838
+ entry.movedOriginalFingerprint = movedOriginal.fingerprint;
1839
+ }
1840
+ }
1841
+ if (!target.preserveOnly) {
1842
+ entry.wrapperWriteStarted = true;
1843
+ const writtenInode = writeShim(target.wrapperPath, target.realPath ?? target.backupPath);
1844
+ entry.writtenWrapperInode = writtenInode;
1845
+ codexShimFreshWriteHookForTests?.();
1846
+ if (process.platform !== "win32") {
1847
+ if (!writtenInode) throw new Error("Codex shim fresh install could not fingerprint the generated wrapper");
1848
+ entry.writtenWrapperFingerprint = ownedWrapperFingerprint(target.wrapperPath, writtenInode);
1849
+ }
1850
+ }
1851
+ } catch (error) {
1852
+ freshApplyError = error instanceof Error ? error : new Error(String(error));
1853
+ break;
1854
+ }
1855
+ }
1856
+ if (process.platform !== "win32") {
1857
+ if (freshApplyError) {
1858
+ try {
1859
+ rollbackFreshShimInstall(freshJournal);
1860
+ } catch (rollbackError) {
1861
+ throw new AggregateError([freshApplyError, rollbackError], "Codex shim installation and rollback failed");
1862
+ }
1863
+ throw freshApplyError;
1864
+ }
1865
+ let unsafe: UnixShimProbeResult = null;
1866
+ let probeError: Error | null = null;
1867
+ try {
1868
+ unsafe = probeUnixShimFiles(targets);
1869
+ } catch (error) {
1870
+ probeError = error instanceof Error ? error : new Error(String(error));
1871
+ }
1872
+ if (probeError) {
1873
+ try {
1874
+ rollbackFreshShimInstall(freshJournal);
1875
+ } catch (rollbackError) {
1876
+ throw new AggregateError([probeError, rollbackError], "Codex shim probe and install rollback failed");
1877
+ }
1878
+ throw probeError;
1879
+ }
1880
+ const wrapperChangedDuringProbe = freshJournal.some(entry => {
1881
+ if (entry.target.preserveOnly) return false;
1882
+ const wrapper = stableShimPathProbe(entry.target.wrapperPath);
1883
+ return !wrapper || !entry.writtenWrapperFingerprint
1884
+ || !sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint);
1885
+ });
1886
+ if (unsafe || wrapperChangedDuringProbe) {
1887
+ rollbackFreshShimInstall(freshJournal);
1888
+ const reason = wrapperChangedDuringProbe
1889
+ ? "the generated wrapper changed during its validation probe"
1890
+ : unsafe === "recursive"
1891
+ ? "the saved launcher resolved back to the generated shim"
1892
+ : unsafe === "timeout"
1893
+ ? `the saved launcher did not finish --version within ${CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS}ms`
1894
+ : unsafe === "descendants"
1895
+ ? "the saved launcher left background descendants running after --version"
1896
+ : unsafe === "cleanup"
1897
+ ? "the saved launcher's probe process group could not be terminated cleanly"
1898
+ : "the saved launcher failed its --version probe";
1899
+ return {
1900
+ installed: false,
1901
+ message: wrapperChangedDuringProbe
1902
+ ? `Refusing Codex autostart shim because ${reason}. The concurrent launcher was preserved, and your previous launcher was kept alongside it as \`<codex>.opencodex-real\`; retry after the Codex update finishes, and remove that backup once you are satisfied the launcher on PATH is the one you want.`
1903
+ : `Refusing Codex autostart shim because ${reason}. The original launcher was restored; reinstall Codex as a concrete executable before enabling codexAutoStart.`,
1904
+ };
1905
+ }
1906
+ } else if (freshApplyError) {
1907
+ throw freshApplyError;
1066
1908
  }
1067
1909
  writeState(primaryState(targets));
1068
1910
  return {
@@ -1095,6 +1937,7 @@ export function autoRestoreCodexShim(options: {
1095
1937
 
1096
1938
  const files = stateFiles(state);
1097
1939
  const replacementProbes = new Map<string, StableShimPathProbe>();
1940
+ const obsoleteShimProbes = new Map<string, StableShimPathProbe>();
1098
1941
  const seen = new Set<string>();
1099
1942
  let healthyCount = 0;
1100
1943
  for (const file of files) {
@@ -1109,15 +1952,19 @@ export function autoRestoreCodexShim(options: {
1109
1952
  if (!probe) return { status: "deferred" };
1110
1953
  if (probe.prefix.includes(SHIM_MARKER)) {
1111
1954
  if (!isHealthyShimProbe(probe, state.platform)) return { status: "ineligible" };
1955
+ if (state.platform !== "win32" && !isCurrentUnixShimProbe(probe)) {
1956
+ obsoleteShimProbes.set(file.wrapperPath, probe);
1957
+ continue;
1958
+ }
1112
1959
  healthyCount += 1;
1113
1960
  continue;
1114
1961
  }
1115
1962
  replacementProbes.set(file.wrapperPath, probe);
1116
1963
  }
1117
1964
 
1118
- if (replacementProbes.size === 0) return { status: "healthy" };
1965
+ if (replacementProbes.size === 0 && obsoleteShimProbes.size === 0) return { status: "healthy" };
1119
1966
  if (!options.enabled()) return { status: "disabled" };
1120
- if (files.length > 1 && healthyCount > 0) {
1967
+ if (files.length > 1 && (healthyCount > 0 || (replacementProbes.size > 0 && obsoleteShimProbes.size > 0))) {
1121
1968
  return {
1122
1969
  status: "deferred",
1123
1970
  message: "Codex shim auto-restore deferred because tracked launcher siblings are in a mixed shim/replacement state.",
@@ -1129,6 +1976,19 @@ export function autoRestoreCodexShim(options: {
1129
1976
  try {
1130
1977
  options.afterRestoreLockAcquired?.();
1131
1978
  (options.stabilitySleep ?? Bun.sleepSync)(CODEX_SHIM_REPLACEMENT_STABLE_MS);
1979
+ if (obsoleteShimProbes.size > 0) {
1980
+ for (const [path, firstProbe] of obsoleteShimProbes) {
1981
+ const secondProbe = stableShimPathProbe(path);
1982
+ if (!secondProbe || isCurrentUnixShimProbe(secondProbe)
1983
+ || !sameStableShimPathProbe(firstProbe, secondProbe)) return { status: "deferred" };
1984
+ }
1985
+ const result = refreshObsoleteUnixShims(files);
1986
+ return result.installed
1987
+ ? { status: "restored", message: result.message }
1988
+ : result.deferred
1989
+ ? { status: "deferred", message: result.message }
1990
+ : { status: "ineligible", message: result.message };
1991
+ }
1132
1992
  const expectedReplacements = new Map<string, ShimPathFingerprint>();
1133
1993
  for (const [path, firstProbe] of replacementProbes) {
1134
1994
  const secondProbe = stableShimPathProbe(path);