@bitkyc08/opencodex 2.7.36 → 2.7.37

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 (66) hide show
  1. package/README.ja.md +8 -1
  2. package/README.ko.md +7 -1
  3. package/README.md +7 -1
  4. package/README.ru.md +7 -1
  5. package/README.zh-CN.md +7 -1
  6. package/gui/dist/assets/index-BhUTxmCy.js +52 -0
  7. package/gui/dist/assets/index-oOZcqVmj.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +22 -2
  11. package/src/adapters/cursor/live-transport.ts +7 -0
  12. package/src/adapters/cursor/message-mapper.ts +3 -0
  13. package/src/adapters/cursor/protobuf-request.ts +223 -27
  14. package/src/adapters/cursor/request-builder.ts +41 -15
  15. package/src/adapters/cursor/thread-continuity.ts +67 -0
  16. package/src/adapters/cursor/types.ts +3 -1
  17. package/src/adapters/cursor.ts +44 -9
  18. package/src/adapters/google.ts +115 -62
  19. package/src/adapters/kiro.ts +3 -17
  20. package/src/adapters/openai-chat.ts +16 -5
  21. package/src/adapters/openai-responses.ts +56 -1
  22. package/src/adapters/run-turn-queue.ts +11 -1
  23. package/src/bridge.ts +139 -69
  24. package/src/chat/outbound.ts +135 -73
  25. package/src/cli/codex-shim-autorestore.ts +45 -0
  26. package/src/cli/doctor.ts +197 -2
  27. package/src/cli/index.ts +17 -3
  28. package/src/cli/status.ts +80 -0
  29. package/src/cli/v2.ts +14 -2
  30. package/src/codex/auth-context.ts +18 -2
  31. package/src/codex/catalog/bundled.ts +83 -27
  32. package/src/codex/catalog/effort.ts +95 -3
  33. package/src/codex/catalog/parsing.ts +17 -0
  34. package/src/codex/catalog/provider-fetch.ts +31 -8
  35. package/src/codex/exec-invocation.ts +22 -0
  36. package/src/codex/model-cache.ts +44 -0
  37. package/src/codex/runtime.ts +529 -0
  38. package/src/codex/shim.ts +608 -10
  39. package/src/combos/resolve.ts +7 -2
  40. package/src/config.ts +32 -1
  41. package/src/lib/bun-stream-caps.ts +88 -0
  42. package/src/lib/crash-guard.ts +3 -1
  43. package/src/lib/sse-decoder.ts +25 -6
  44. package/src/responses/parser.ts +2 -1
  45. package/src/responses/state.ts +10 -2
  46. package/src/server/auth-cors.ts +4 -1
  47. package/src/server/index.ts +191 -1
  48. package/src/server/live.ts +491 -0
  49. package/src/server/management/config-routes.ts +79 -3
  50. package/src/server/management/provider-routes.ts +2 -0
  51. package/src/server/management/shared.ts +6 -6
  52. package/src/server/management/system-routes.ts +65 -0
  53. package/src/server/management-api.ts +3 -1
  54. package/src/server/memory-watchdog.ts +112 -0
  55. package/src/server/relay-eager.ts +199 -0
  56. package/src/server/relay.ts +131 -81
  57. package/src/server/responses/collaboration.ts +20 -3
  58. package/src/server/responses/core.ts +236 -21
  59. package/src/server/responses/encrypted-payload.ts +118 -41
  60. package/src/server/ws-bridge.ts +7 -0
  61. package/src/types.ts +25 -0
  62. package/src/usage/cost.ts +0 -0
  63. package/src/usage/expected-prices.ts +19 -0
  64. package/src/usage/summary.ts +11 -8
  65. package/gui/dist/assets/index-BpX-hoSd.css +0 -1
  66. package/gui/dist/assets/index-ZmFopEYw.js +0 -52
package/src/codex/shim.ts CHANGED
@@ -1,12 +1,35 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { delimiter, dirname, extname, join, posix } from "node:path";
2
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import {
4
+ chmodSync,
5
+ closeSync,
6
+ existsSync,
7
+ fstatSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ openSync,
11
+ readFileSync,
12
+ readdirSync,
13
+ readSync,
14
+ renameSync,
15
+ rmdirSync,
16
+ statSync,
17
+ type Stats,
18
+ unlinkSync,
19
+ writeFileSync,
20
+ } from "node:fs";
3
21
  import { getConfigDir } from "../config";
4
22
  import { durableBunPath } from "../lib/bun-runtime";
23
+ import { isProcessAlive } from "../lib/process-control";
5
24
  import { serviceApiTokenFilePath } from "../lib/service-secrets";
6
25
  import { windowsEnvIndirectBatchValue } from "../lib/win-paths";
7
26
  import { isWslRuntime, wslAutomountRoot } from "./home";
8
27
 
9
28
  const SHIM_MARKER = "opencodex codex autostart shim";
29
+ const CODEX_SHIM_PROBE_BYTES = 16 * 1024;
30
+ export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100;
31
+ export const CODEX_SHIM_STATE_MAX_BYTES = 1024 * 1024;
32
+ const CODEX_SHIM_RESTORE_LOCK_STALE_MS = 30_000;
10
33
  let lastShimDiscoveryError: string | null = null;
11
34
  /** Last human-readable reason discovery returned null (exposed for doctor/tests). */
12
35
  export function lastCodexDiscoveryError(): string | null {
@@ -67,6 +90,33 @@ interface ShimFileState {
67
90
  preserveOnly?: boolean;
68
91
  }
69
92
 
93
+ interface ShimPathFingerprint {
94
+ dev: number;
95
+ ino: number;
96
+ kind: "file" | "symlink";
97
+ mode: number;
98
+ size: number;
99
+ mtimeMs: number;
100
+ ctimeMs: number;
101
+ target?: Omit<ShimPathFingerprint, "target">;
102
+ }
103
+
104
+ interface StableShimPathProbe {
105
+ fingerprint: ShimPathFingerprint;
106
+ prefix: string;
107
+ }
108
+
109
+ interface InstallCodexShimInternalOptions {
110
+ expectedReplacements?: ReadonlyMap<string, ShimPathFingerprint>;
111
+ allowFreshInstall: boolean;
112
+ beforeGuardedRefresh?: (wrapperPath: string, index: number) => void;
113
+ }
114
+
115
+ export type CodexShimAutoRestoreResult =
116
+ | { status: "not-installed" | "healthy" | "disabled" }
117
+ | { status: "ineligible" | "deferred"; message?: string }
118
+ | { status: "restored"; message: string };
119
+
70
120
  function cliEntry(): { bun: string; cli: string } {
71
121
  // Bundled Bun path (survives `ocx update`); all three shim builders
72
122
  // (Unix / Windows cmd / Windows PowerShell) receive it via this entry.
@@ -99,6 +149,99 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean {
99
149
  }
100
150
  }
101
151
 
152
+ function readShimProbePrefix(path: string): string {
153
+ const fd = openSync(path, "r");
154
+ try {
155
+ const buffer = Buffer.allocUnsafe(CODEX_SHIM_PROBE_BYTES);
156
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
157
+ return buffer.toString("utf8", 0, bytesRead);
158
+ } finally {
159
+ closeSync(fd);
160
+ }
161
+ }
162
+
163
+ function statFingerprint(path: string, follow: boolean): Omit<ShimPathFingerprint, "target"> | null {
164
+ try {
165
+ const stat = follow ? statSync(path) : lstatSync(path);
166
+ if (follow ? !stat.isFile() : (!stat.isFile() && !stat.isSymbolicLink())) return null;
167
+ return {
168
+ dev: stat.dev,
169
+ ino: stat.ino,
170
+ kind: stat.isSymbolicLink() ? "symlink" : "file",
171
+ mode: stat.mode,
172
+ size: stat.size,
173
+ mtimeMs: stat.mtimeMs,
174
+ ctimeMs: stat.ctimeMs,
175
+ };
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ function sameFingerprint(
182
+ left: ShimPathFingerprint | Omit<ShimPathFingerprint, "target">,
183
+ right: ShimPathFingerprint | Omit<ShimPathFingerprint, "target">,
184
+ ): boolean {
185
+ return left.dev === right.dev
186
+ && left.ino === right.ino
187
+ && left.kind === right.kind
188
+ && left.mode === right.mode
189
+ && left.size === right.size
190
+ && left.mtimeMs === right.mtimeMs
191
+ && left.ctimeMs === right.ctimeMs
192
+ && (!("target" in left) || !("target" in right)
193
+ ? true
194
+ : left.target === undefined && right.target === undefined
195
+ ? true
196
+ : left.target !== undefined && right.target !== undefined
197
+ ? sameFingerprint(left.target, right.target)
198
+ : false);
199
+ }
200
+
201
+ function stableShimPathProbe(path: string): StableShimPathProbe | null {
202
+ const before = statFingerprint(path, false);
203
+ if (!before) return null;
204
+ const targetBefore = before.kind === "symlink" ? statFingerprint(path, true) : undefined;
205
+ if (before.kind === "symlink" && !targetBefore) return null;
206
+ let prefix: string;
207
+ try {
208
+ prefix = readShimProbePrefix(path);
209
+ } catch {
210
+ return null;
211
+ }
212
+ const targetAfter = before.kind === "symlink" ? statFingerprint(path, true) : undefined;
213
+ const after = statFingerprint(path, false);
214
+ if (!after || !sameFingerprint(before, after)) return null;
215
+ if (before.kind === "symlink") {
216
+ if (!targetBefore || !targetAfter || !sameFingerprint(targetBefore, targetAfter)) return null;
217
+ }
218
+ const fingerprint: ShimPathFingerprint = {
219
+ ...before,
220
+ ...(targetBefore ? { target: targetBefore } : {}),
221
+ };
222
+ const contentSize = fingerprint.target?.size ?? fingerprint.size;
223
+ return contentSize > 0 ? { fingerprint, prefix } : null;
224
+ }
225
+
226
+ function sameStableShimPathProbe(left: StableShimPathProbe, right: StableShimPathProbe): boolean {
227
+ return left.prefix === right.prefix && sameFingerprint(left.fingerprint, right.fingerprint);
228
+ }
229
+
230
+ function isHealthyShimProbe(probe: StableShimPathProbe, platform: NodeJS.Platform): boolean {
231
+ if (probe.prefix.length < 180 || !probe.prefix.includes(SHIM_MARKER) || !probe.prefix.includes("ensure")) return false;
232
+ const mode = probe.fingerprint.target?.mode ?? probe.fingerprint.mode;
233
+ return platform === "win32" || (mode & 0o111) !== 0;
234
+ }
235
+
236
+ function hasUsableBackingPath(file: ShimFileState): boolean {
237
+ return [existsSync(file.backupPath) ? file.backupPath : undefined, file.realPath]
238
+ .some(path => {
239
+ if (!path) return false;
240
+ const fingerprint = statFingerprint(path, true);
241
+ return fingerprint !== null && fingerprint.size > 0;
242
+ });
243
+ }
244
+
102
245
  /**
103
246
  * A PATH entry that reaches Windows through WSL drive interop
104
247
  * (`<automount-root>/<drive>/...`; root defaults to /mnt, configurable via
@@ -354,12 +497,62 @@ exit $LASTEXITCODE
354
497
  `;
355
498
  }
356
499
 
357
- function readState(): ShimState | null {
500
+ interface ShimStateReadResult {
501
+ state: ShimState | null;
502
+ warning?: string;
503
+ }
504
+
505
+ function fileErrorCode(error: unknown): string | undefined {
506
+ return error && typeof error === "object" && "code" in error
507
+ ? String((error as { code?: unknown }).code)
508
+ : undefined;
509
+ }
510
+
511
+ function readBoundedRegularFile(path: string, maxBytes: number): { content: string } | { warning: string } | null {
512
+ let fd: number;
513
+ try {
514
+ fd = openSync(path, "r");
515
+ } catch (error) {
516
+ if (fileErrorCode(error) === "ENOENT") return null;
517
+ return { warning: `Codex shim state could not be opened as a regular file at ${path}.` };
518
+ }
358
519
  try {
359
- const value = JSON.parse(readFileSync(statePath(), "utf8")) as unknown;
360
- if (!value || typeof value !== "object") return null;
520
+ const before = fstatSync(fd);
521
+ if (!before.isFile()) return { warning: `Codex shim state is not a regular file at ${path}; auto-restore skipped.` };
522
+ if (before.size > maxBytes) {
523
+ return { warning: `Codex shim state exceeds the 1 MiB startup limit at ${path}; auto-restore skipped.` };
524
+ }
525
+ const buffer = Buffer.allocUnsafe(before.size);
526
+ let offset = 0;
527
+ while (offset < buffer.length) {
528
+ const bytesRead = readSync(fd, buffer, offset, buffer.length - offset, offset);
529
+ if (bytesRead === 0) return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` };
530
+ offset += bytesRead;
531
+ }
532
+ const extra = Buffer.allocUnsafe(1);
533
+ if (readSync(fd, extra, 0, 1, offset) !== 0) {
534
+ return { warning: `Codex shim state exceeds the 1 MiB startup limit at ${path}; auto-restore skipped.` };
535
+ }
536
+ const after = fstatSync(fd);
537
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size
538
+ || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
539
+ return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` };
540
+ }
541
+ return { content: buffer.toString("utf8") };
542
+ } finally {
543
+ closeSync(fd);
544
+ }
545
+ }
546
+
547
+ function readStateResult(): ShimStateReadResult {
548
+ const bounded = readBoundedRegularFile(statePath(), CODEX_SHIM_STATE_MAX_BYTES);
549
+ if (!bounded) return { state: null };
550
+ if ("warning" in bounded) return { state: null, warning: bounded.warning };
551
+ try {
552
+ const value = JSON.parse(bounded.content) as unknown;
553
+ if (!value || typeof value !== "object") return { state: null };
361
554
  const state = value as Record<string, unknown>;
362
- if (typeof state.platform !== "string") return null;
555
+ if (typeof state.platform !== "string") return { state: null };
363
556
  const validFile = (item: unknown): item is ShimFileState => {
364
557
  if (!item || typeof item !== "object") return false;
365
558
  const file = item as Record<string, unknown>;
@@ -370,16 +563,20 @@ function readState(): ShimState | null {
370
563
  && (file.preserveOnly === undefined || typeof file.preserveOnly === "boolean");
371
564
  };
372
565
  if (state.wrappers !== undefined) {
373
- if (!Array.isArray(state.wrappers) || state.wrappers.length === 0 || !state.wrappers.every(validFile)) return null;
566
+ if (!Array.isArray(state.wrappers) || state.wrappers.length === 0 || !state.wrappers.every(validFile)) return { state: null };
374
567
  } else if (!validFile(state)) {
375
- return null;
568
+ return { state: null };
376
569
  }
377
- return state as unknown as ShimState;
570
+ return { state: state as unknown as ShimState };
378
571
  } catch {
379
- return null;
572
+ return { state: null };
380
573
  }
381
574
  }
382
575
 
576
+ function readState(): ShimState | null {
577
+ return readStateResult().state;
578
+ }
579
+
383
580
  function statePath(): string {
384
581
  return join(getConfigDir(), "codex-shim.json");
385
582
  }
@@ -468,10 +665,329 @@ function refreshShimFile(file: ShimFileState): boolean {
468
665
  return false;
469
666
  }
470
667
 
471
- export function installCodexShim(): { installed: boolean; message: string } {
668
+ interface GuardedRefreshOperation {
669
+ file: ShimFileState;
670
+ expectedReplacement: ShimPathFingerprint;
671
+ sourcePath: string;
672
+ }
673
+
674
+ interface GuardedRefreshJournalEntry {
675
+ operation: GuardedRefreshOperation;
676
+ stagedOldBackupPath?: string;
677
+ replacementMovedToBackup: boolean;
678
+ wrapperWriteStarted: boolean;
679
+ }
680
+
681
+ let guardedRefreshTransactionId = 0;
682
+
683
+ interface ShimRestoreLock {
684
+ release(): void;
685
+ }
686
+
687
+ interface ShimRestoreLockRecord {
688
+ version: 1;
689
+ token: string;
690
+ pid: number;
691
+ createdAt: number;
692
+ }
693
+
694
+ interface ShimRestoreLockSnapshot {
695
+ record: ShimRestoreLockRecord;
696
+ ownerPath: string;
697
+ lockIdentity: Pick<Stats, "dev" | "ino">;
698
+ fingerprint: ShimPathFingerprint;
699
+ }
700
+
701
+ function restoreLockPath(): string {
702
+ return join(getConfigDir(), "codex-shim.autorestore.lock");
703
+ }
704
+
705
+ function sameFileIdentity(left: Pick<Stats, "dev" | "ino">, right: Pick<Stats, "dev" | "ino">): boolean {
706
+ return left.dev === right.dev && left.ino === right.ino;
707
+ }
708
+
709
+ function readShimRestoreLockSnapshot(path: string): ShimRestoreLockSnapshot | null {
710
+ let lockIdentity: Stats;
711
+ let entries: string[];
712
+ try {
713
+ lockIdentity = lstatSync(path);
714
+ if (!lockIdentity.isDirectory()) return null;
715
+ entries = readdirSync(path);
716
+ } catch {
717
+ return null;
718
+ }
719
+ if (entries.length !== 1 || !entries[0].endsWith(".json")) return null;
720
+ const ownerPath = join(path, entries[0]);
721
+ const probe = stableShimPathProbe(ownerPath);
722
+ if (!probe || probe.fingerprint.kind !== "file" || probe.fingerprint.size > 4096) return null;
723
+ try {
724
+ const value = JSON.parse(probe.prefix) as Partial<ShimRestoreLockRecord>;
725
+ if (value.version !== 1 || typeof value.token !== "string" || value.token.length === 0
726
+ || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0
727
+ || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null;
728
+ if (entries[0] !== `${value.token}.json`) return null;
729
+ const currentLockIdentity = lstatSync(path);
730
+ if (!currentLockIdentity.isDirectory() || !sameFileIdentity(lockIdentity, currentLockIdentity)) return null;
731
+ return {
732
+ record: value as ShimRestoreLockRecord,
733
+ ownerPath,
734
+ lockIdentity,
735
+ fingerprint: probe.fingerprint,
736
+ };
737
+ } catch {
738
+ return null;
739
+ }
740
+ }
741
+
742
+ function sameShimRestoreLock(left: ShimRestoreLockSnapshot, right: ShimRestoreLockSnapshot): boolean {
743
+ return left.record.token === right.record.token
744
+ && sameFileIdentity(left.lockIdentity, right.lockIdentity)
745
+ && sameFingerprint(left.fingerprint, right.fingerprint);
746
+ }
747
+
748
+ function reclaimStaleRestoreLock(path: string, beforeDelete?: () => void): boolean {
749
+ const observed = readShimRestoreLockSnapshot(path);
750
+ if (!observed) return false;
751
+ const createdAt = Math.max(observed.record.createdAt, observed.fingerprint.mtimeMs);
752
+ if (Date.now() - createdAt <= CODEX_SHIM_RESTORE_LOCK_STALE_MS) return false;
753
+ if (isProcessAlive(observed.record.pid)) return false;
754
+ const current = readShimRestoreLockSnapshot(path);
755
+ if (!current || !sameShimRestoreLock(observed, current)) return false;
756
+ beforeDelete?.();
757
+ try {
758
+ // The token is part of the owner filename. Even if the lock directory is
759
+ // replaced after the comparison, this unlink cannot target a successor's
760
+ // differently named owner record.
761
+ unlinkSync(observed.ownerPath);
762
+ rmdirSync(path);
763
+ return true;
764
+ } catch {
765
+ return false;
766
+ }
767
+ }
768
+
769
+ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreLock | null {
770
+ const dir = getConfigDir();
771
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
772
+ const path = restoreLockPath();
773
+ for (let attempt = 0; attempt < 2; attempt += 1) {
774
+ let fd: number | null = null;
775
+ let identity: Stats | null = null;
776
+ let createdDirectory = false;
777
+ const record: ShimRestoreLockRecord = {
778
+ version: 1,
779
+ token: `${process.pid}-${Date.now()}-${randomUUID()}`,
780
+ pid: process.pid,
781
+ createdAt: Date.now(),
782
+ };
783
+ const ownerPath = join(path, `${record.token}.json`);
784
+ try {
785
+ mkdirSync(path, { mode: 0o700 });
786
+ createdDirectory = true;
787
+ fd = openSync(ownerPath, "wx", 0o600);
788
+ identity = fstatSync(fd);
789
+ writeFileSync(fd, `${JSON.stringify(record)}\n`, "utf8");
790
+ identity = fstatSync(fd);
791
+ let released = false;
792
+ return {
793
+ release(): void {
794
+ if (released) return;
795
+ released = true;
796
+ try { closeSync(fd!); } catch { /* stale recovery handles an uncertain lock */ }
797
+ try {
798
+ const current = readShimRestoreLockSnapshot(path);
799
+ if (identity && current && current.record.token === record.token
800
+ && sameFileIdentity(identity, current.fingerprint)) {
801
+ unlinkSync(ownerPath);
802
+ rmdirSync(path);
803
+ }
804
+ } catch { /* stale recovery handles release failures */ }
805
+ },
806
+ };
807
+ } catch (error) {
808
+ if (fd !== null) {
809
+ try { closeSync(fd); } catch { /* best-effort close before ownership cleanup */ }
810
+ try {
811
+ const current = readShimRestoreLockSnapshot(path);
812
+ if (identity && current && current.record.token === record.token
813
+ && sameFileIdentity(identity, current.fingerprint)) {
814
+ unlinkSync(ownerPath);
815
+ rmdirSync(path);
816
+ }
817
+ } catch { /* leave an uncertain lock for stale recovery */ }
818
+ } else if (createdDirectory) {
819
+ try { rmdirSync(path); } catch { /* another owner exists or cleanup is uncertain */ }
820
+ }
821
+ if (fileErrorCode(error) !== "EEXIST") throw error;
822
+ if (attempt === 0 && reclaimStaleRestoreLock(path, beforeStaleDelete)) continue;
823
+ return null;
824
+ }
825
+ }
826
+ return null;
827
+ }
828
+
829
+ function planGuardedRefreshTransaction(
830
+ files: readonly ShimFileState[],
831
+ expectedReplacements: ReadonlyMap<string, ShimPathFingerprint>,
832
+ ): GuardedRefreshOperation[] | null {
833
+ const operations: GuardedRefreshOperation[] = [];
834
+ const seen = new Set<string>();
835
+ for (const file of files) {
836
+ if (seen.has(file.wrapperPath)) return null;
837
+ seen.add(file.wrapperPath);
838
+ if (file.preserveOnly) {
839
+ if (!existsSync(file.backupPath) || existsSync(file.originalPath)) return null;
840
+ continue;
841
+ }
842
+ if (!hasUsableBackingPath(file)) return null;
843
+ const probe = stableShimPathProbe(file.wrapperPath);
844
+ if (!probe) return null;
845
+ const expectedReplacement = expectedReplacements.get(file.wrapperPath);
846
+ if (!expectedReplacement) {
847
+ if (!isHealthyShimProbe(probe, process.platform)) return null;
848
+ continue;
849
+ }
850
+ if (file.wrapperPath !== file.originalPath
851
+ || probe.prefix.includes(SHIM_MARKER)
852
+ || !sameFingerprint(probe.fingerprint, expectedReplacement)) return null;
853
+ operations.push({ file, expectedReplacement, sourcePath: file.wrapperPath });
854
+ }
855
+ if (operations.length !== expectedReplacements.size) return null;
856
+ return operations;
857
+ }
858
+
859
+ function rollbackGuardedRefresh(journal: readonly GuardedRefreshJournalEntry[]): Error[] {
860
+ const rollbackErrors: Error[] = [];
861
+ const attempt = (operation: () => void): void => {
862
+ try {
863
+ operation();
864
+ } catch (error) {
865
+ rollbackErrors.push(error instanceof Error ? error : new Error(String(error)));
866
+ }
867
+ };
868
+ for (const entry of [...journal].reverse()) {
869
+ attempt(() => {
870
+ if (entry.wrapperWriteStarted && existsSync(entry.operation.file.wrapperPath)) {
871
+ unlinkSync(entry.operation.file.wrapperPath);
872
+ }
873
+ });
874
+ attempt(() => {
875
+ if (entry.replacementMovedToBackup && existsSync(entry.operation.file.backupPath)) {
876
+ renameSync(entry.operation.file.backupPath, entry.operation.sourcePath);
877
+ }
878
+ });
879
+ attempt(() => {
880
+ if (entry.stagedOldBackupPath && existsSync(entry.stagedOldBackupPath)) {
881
+ renameSync(entry.stagedOldBackupPath, entry.operation.file.backupPath);
882
+ }
883
+ });
884
+ }
885
+ return rollbackErrors;
886
+ }
887
+
888
+ function applyGuardedRefreshTransaction(
889
+ operations: readonly GuardedRefreshOperation[],
890
+ beforeGuardedRefresh?: (wrapperPath: string, index: number) => void,
891
+ commitState?: () => void,
892
+ ): boolean {
893
+ const journal: GuardedRefreshJournalEntry[] = [];
894
+ let applyError: Error | null = null;
895
+ let fingerprintMismatch = false;
896
+ const transactionId = `${process.pid}-${++guardedRefreshTransactionId}`;
897
+
898
+ for (const [index, operation] of operations.entries()) {
899
+ beforeGuardedRefresh?.(operation.sourcePath, index);
900
+ const probe = stableShimPathProbe(operation.sourcePath);
901
+ if (!probe || !sameFingerprint(probe.fingerprint, operation.expectedReplacement)) {
902
+ fingerprintMismatch = true;
903
+ break;
904
+ }
905
+ const entry: GuardedRefreshJournalEntry = {
906
+ operation,
907
+ replacementMovedToBackup: false,
908
+ wrapperWriteStarted: false,
909
+ };
910
+ journal.push(entry);
911
+ try {
912
+ if (existsSync(operation.file.backupPath)) {
913
+ entry.stagedOldBackupPath = `${operation.file.backupPath}.autorestore-${transactionId}-${index}`;
914
+ if (existsSync(entry.stagedOldBackupPath)) unlinkSync(entry.stagedOldBackupPath);
915
+ renameSync(operation.file.backupPath, entry.stagedOldBackupPath);
916
+ }
917
+ renameSync(operation.sourcePath, operation.file.backupPath);
918
+ entry.replacementMovedToBackup = true;
919
+ entry.wrapperWriteStarted = true;
920
+ writeShim(operation.file.wrapperPath, operation.file.realPath ?? operation.file.backupPath);
921
+ } catch (error) {
922
+ applyError = error instanceof Error ? error : new Error(String(error));
923
+ break;
924
+ }
925
+ }
926
+
927
+ if (!fingerprintMismatch && !applyError && commitState) {
928
+ try {
929
+ commitState();
930
+ } catch (error) {
931
+ applyError = error instanceof Error ? error : new Error(String(error));
932
+ }
933
+ }
934
+
935
+ if (fingerprintMismatch || applyError) {
936
+ const rollbackErrors = rollbackGuardedRefresh(journal);
937
+ if (applyError || rollbackErrors.length > 0) {
938
+ throw new AggregateError(
939
+ [...(applyError ? [applyError] : []), ...rollbackErrors],
940
+ "Codex shim guarded refresh failed",
941
+ );
942
+ }
943
+ return false;
944
+ }
945
+
946
+ const cleanupErrors: Error[] = [];
947
+ for (const entry of journal) {
948
+ try {
949
+ if (entry.stagedOldBackupPath && existsSync(entry.stagedOldBackupPath)) unlinkSync(entry.stagedOldBackupPath);
950
+ } catch (error) {
951
+ cleanupErrors.push(error instanceof Error ? error : new Error(String(error)));
952
+ }
953
+ }
954
+ if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "Codex shim guarded refresh cleanup failed");
955
+ return true;
956
+ }
957
+
958
+ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { installed: boolean; message: string } {
472
959
  const existing = readState();
473
960
  if (existing) {
474
961
  const files = stateFiles(existing);
962
+ if (options.expectedReplacements) {
963
+ const operations = planGuardedRefreshTransaction(files, options.expectedReplacements);
964
+ if (!operations || operations.length === 0) {
965
+ return { installed: false, message: "Codex shim auto-restore deferred because tracked launchers changed." };
966
+ }
967
+ const originalStateBytes = readFileSync(statePath());
968
+ const commitState = (): void => {
969
+ try {
970
+ writeState(primaryState(files));
971
+ } catch (writeError) {
972
+ try {
973
+ writeFileSync(statePath(), originalStateBytes);
974
+ } catch (restoreError) {
975
+ throw new AggregateError(
976
+ [writeError, restoreError],
977
+ "Codex shim state commit and restoration failed",
978
+ );
979
+ }
980
+ throw writeError;
981
+ }
982
+ };
983
+ if (!applyGuardedRefreshTransaction(operations, options.beforeGuardedRefresh, commitState)) {
984
+ return { installed: false, message: "Codex shim auto-restore deferred because tracked launchers changed." };
985
+ }
986
+ return {
987
+ installed: true,
988
+ message: `Codex update detected. Backed up new launcher and refreshed shim at ${files.map(f => f.wrapperPath).join(", ")}.`,
989
+ };
990
+ }
475
991
  let refreshed = false;
476
992
  for (const file of files) refreshed = refreshShimFile(file) || refreshed;
477
993
  const allInstalled = files.every(file => file.preserveOnly
@@ -494,6 +1010,10 @@ export function installCodexShim(): { installed: boolean; message: string } {
494
1010
  }
495
1011
  }
496
1012
 
1013
+ if (!options.allowFreshInstall) {
1014
+ return { installed: false, message: "Codex shim auto-restore requires a valid prior installation." };
1015
+ }
1016
+
497
1017
  const targets: ShimFileState[] | null = process.platform === "win32"
498
1018
  ? findWindowsCodexTargets()
499
1019
  : (() => {
@@ -516,6 +1036,84 @@ export function installCodexShim(): { installed: boolean; message: string } {
516
1036
  };
517
1037
  }
518
1038
 
1039
+ export function installCodexShim(): { installed: boolean; message: string } {
1040
+ return installCodexShimInternal({ allowFreshInstall: true });
1041
+ }
1042
+
1043
+ export function autoRestoreCodexShim(options: {
1044
+ enabled: () => boolean;
1045
+ stabilitySleep?: (ms: number) => void;
1046
+ /** Narrow deterministic seam used to hold the interprocess lock in tests. */
1047
+ afterRestoreLockAcquired?: () => void;
1048
+ /** Narrow deterministic seam for stale-lock compare-and-delete tests. */
1049
+ beforeStaleRestoreLockDelete?: () => void;
1050
+ /** Narrow deterministic race seam for the guarded transaction tests. */
1051
+ beforeGuardedRefresh?: (wrapperPath: string, index: number) => void;
1052
+ }): CodexShimAutoRestoreResult {
1053
+ const stateRead = readStateResult();
1054
+ const state = stateRead.state;
1055
+ if (!state) {
1056
+ if (stateRead.warning) return { status: "ineligible", message: stateRead.warning };
1057
+ return { status: existsSync(statePath()) ? "ineligible" : "not-installed" };
1058
+ }
1059
+ if (state.platform !== process.platform) return { status: "ineligible" };
1060
+
1061
+ const files = stateFiles(state);
1062
+ const replacementProbes = new Map<string, StableShimPathProbe>();
1063
+ const seen = new Set<string>();
1064
+ let healthyCount = 0;
1065
+ for (const file of files) {
1066
+ if (seen.has(file.wrapperPath)) return { status: "ineligible" };
1067
+ seen.add(file.wrapperPath);
1068
+ if (file.preserveOnly) {
1069
+ if (!existsSync(file.backupPath) || existsSync(file.originalPath)) return { status: "ineligible" };
1070
+ continue;
1071
+ }
1072
+ if (!existsSync(file.wrapperPath) || !hasUsableBackingPath(file)) return { status: "ineligible" };
1073
+ const probe = stableShimPathProbe(file.wrapperPath);
1074
+ if (!probe) return { status: "deferred" };
1075
+ if (probe.prefix.includes(SHIM_MARKER)) {
1076
+ if (!isHealthyShimProbe(probe, state.platform)) return { status: "ineligible" };
1077
+ healthyCount += 1;
1078
+ continue;
1079
+ }
1080
+ replacementProbes.set(file.wrapperPath, probe);
1081
+ }
1082
+
1083
+ if (replacementProbes.size === 0) return { status: "healthy" };
1084
+ if (!options.enabled()) return { status: "disabled" };
1085
+ if (files.length > 1 && healthyCount > 0) {
1086
+ return {
1087
+ status: "deferred",
1088
+ message: "Codex shim auto-restore deferred because tracked launcher siblings are in a mixed shim/replacement state.",
1089
+ };
1090
+ }
1091
+
1092
+ const lock = tryAcquireShimRestoreLock(options.beforeStaleRestoreLockDelete);
1093
+ if (!lock) return { status: "deferred" };
1094
+ try {
1095
+ options.afterRestoreLockAcquired?.();
1096
+ (options.stabilitySleep ?? Bun.sleepSync)(CODEX_SHIM_REPLACEMENT_STABLE_MS);
1097
+ const expectedReplacements = new Map<string, ShimPathFingerprint>();
1098
+ for (const [path, firstProbe] of replacementProbes) {
1099
+ const secondProbe = stableShimPathProbe(path);
1100
+ if (!secondProbe || secondProbe.prefix.includes(SHIM_MARKER)
1101
+ || !sameStableShimPathProbe(firstProbe, secondProbe)) return { status: "deferred" };
1102
+ expectedReplacements.set(path, secondProbe.fingerprint);
1103
+ }
1104
+ const result = installCodexShimInternal({
1105
+ allowFreshInstall: false,
1106
+ expectedReplacements,
1107
+ beforeGuardedRefresh: options.beforeGuardedRefresh,
1108
+ });
1109
+ return result.installed
1110
+ ? { status: "restored", message: result.message }
1111
+ : { status: "deferred" };
1112
+ } finally {
1113
+ lock.release();
1114
+ }
1115
+ }
1116
+
519
1117
  export function uninstallCodexShim(): { removed: boolean; message: string } {
520
1118
  const state = readState();
521
1119
  if (!state) return { removed: false, message: "Codex autostart shim is not installed." };