@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.
- package/README.ja.md +8 -1
- package/README.ko.md +7 -1
- package/README.md +7 -1
- package/README.ru.md +7 -1
- package/README.zh-CN.md +7 -1
- package/gui/dist/assets/index-BhUTxmCy.js +52 -0
- package/gui/dist/assets/index-oOZcqVmj.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +7 -0
- package/src/adapters/cursor/message-mapper.ts +3 -0
- package/src/adapters/cursor/protobuf-request.ts +223 -27
- package/src/adapters/cursor/request-builder.ts +41 -15
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +3 -1
- package/src/adapters/cursor.ts +44 -9
- package/src/adapters/google.ts +115 -62
- package/src/adapters/kiro.ts +3 -17
- package/src/adapters/openai-chat.ts +16 -5
- package/src/adapters/openai-responses.ts +56 -1
- package/src/adapters/run-turn-queue.ts +11 -1
- package/src/bridge.ts +139 -69
- package/src/chat/outbound.ts +135 -73
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/doctor.ts +197 -2
- package/src/cli/index.ts +17 -3
- package/src/cli/status.ts +80 -0
- package/src/cli/v2.ts +14 -2
- package/src/codex/auth-context.ts +18 -2
- package/src/codex/catalog/bundled.ts +83 -27
- package/src/codex/catalog/effort.ts +95 -3
- package/src/codex/catalog/parsing.ts +17 -0
- package/src/codex/catalog/provider-fetch.ts +31 -8
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/model-cache.ts +44 -0
- package/src/codex/runtime.ts +529 -0
- package/src/codex/shim.ts +608 -10
- package/src/combos/resolve.ts +7 -2
- package/src/config.ts +32 -1
- package/src/lib/bun-stream-caps.ts +88 -0
- package/src/lib/crash-guard.ts +3 -1
- package/src/lib/sse-decoder.ts +25 -6
- package/src/responses/parser.ts +2 -1
- package/src/responses/state.ts +10 -2
- package/src/server/auth-cors.ts +4 -1
- package/src/server/index.ts +191 -1
- package/src/server/live.ts +491 -0
- package/src/server/management/config-routes.ts +79 -3
- package/src/server/management/provider-routes.ts +2 -0
- package/src/server/management/shared.ts +6 -6
- package/src/server/management/system-routes.ts +65 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/memory-watchdog.ts +112 -0
- package/src/server/relay-eager.ts +199 -0
- package/src/server/relay.ts +131 -81
- package/src/server/responses/collaboration.ts +20 -3
- package/src/server/responses/core.ts +236 -21
- package/src/server/responses/encrypted-payload.ts +118 -41
- package/src/server/ws-bridge.ts +7 -0
- package/src/types.ts +25 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +19 -0
- package/src/usage/summary.ts +11 -8
- package/gui/dist/assets/index-BpX-hoSd.css +0 -1
- 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 {
|
|
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
|
-
|
|
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
|
|
360
|
-
if (!
|
|
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
|
-
|
|
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." };
|