-
Notifications
You must be signed in to change notification settings - Fork 516
Expand file tree
/
Copy pathindex.ts
More file actions
executable file
·1098 lines (1053 loc) · 45 KB
/
Copy pathindex.ts
File metadata and controls
executable file
·1098 lines (1053 loc) · 45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bun
import { spawn } from "node:child_process";
import { currentExternalCodexModelProvider, restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject";
import { stripGrokConfig } from "../grok/inject";
import { restoreLegacyOpenaiHistory } from "../codex/history-provider";
import { reconcileJournal } from "../codex/journal";
import {
codexAutoStartEnabled,
getConfigDir,
loadConfig,
readPid,
readPidFileValue,
readRuntimePort,
removePid,
removePidIfValueIs,
removeRuntimePort,
removeRuntimePortIfPidIs,
saveConfig,
writePid,
writeRuntimePort,
} from "../config";
import { collectStatus } from "./status";
import { dispatchInternalCliCommand, type InternalCliCommand } from "./internal-dispatch";
import { runTrayProxyRestart, runTrayProxyStart } from "./tray-proxy";
import { installCrashGuards } from "../lib/crash-guard";
import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help";
import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
import { stopProxy } from "../lib/process-control";
import { loadServiceTokenFromFile } from "../lib/service-secrets";
import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
import { startupHealthSummary } from "../codex/autostart-health";
import { drainAndShutdown, isRecyclingForExit, startServer } from "../server";
import { injectSystemEnv, revertSystemEnv } from "../server/system-env";
import { buildDesktop3pRegistry } from "../claude/desktop-3p";
import { installShellHook, uninstallShellHook } from "../server/system-env";
import { startTokenGuardian } from "../oauth/token-guardian";
import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
import { maybeShowStarPrompt } from "./star-prompt";
import { scheduleCatalogPrewarm } from "./catalog-prewarm";
import { maybeShowUpdatePrompt } from "../update/notify";
import { syncModelsToCodex } from "../codex/sync";
import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
import { removeOwnedConfigState } from "../lib/config-ownership";
const args = process.argv.slice(2);
const command = args[0];
if (command === "--version" || command === "-v" || command === "version") {
printVersion();
process.exit(0);
}
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
if (command === "help" && args[1]) printSubcommandUsage(args[1]);
else printUsage();
process.exit(0);
}
if (command !== undefined && command !== "help" && hasHelpFlag(args.slice(1))) {
printSubcommandUsage(command);
process.exit(0);
}
maybeAutoRestoreCodexShim(command, args);
function parsePortOption(): number | undefined {
if (args.length === 1) return undefined;
if (args.length !== 3 || args[1] !== "--port") {
console.error("Usage: ocx start [--port <port>]");
process.exit(1);
}
const portIdx = args.indexOf("--port");
if (portIdx === -1) return undefined;
const value = args[portIdx + 1];
const port = value && /^\d+$/.test(value) ? Number(value) : NaN;
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
console.error("Invalid port number");
process.exit(1);
}
return port;
}
async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
// Runtime-state-first with identity: finds the proxy even when it started on a
// fallback port, and never mistakes a foreign 200 for our proxy.
const live = await findLiveProxy();
if (live) return live;
await new Promise(resolve => setTimeout(resolve, 150));
}
return null;
}
/**
* A Grok fence sync that throws is best-effort by design — it must never block startup.
* Reporting nothing, however, is what lets a STALE fence survive: `~/.grok/config.toml`
* keeps naming whatever port the last successful sync wrote, and once that listener is
* gone every grok turn retries against a refused connection while our own log stays
* silent (2026-07-27 field report: 8 entries pinned to a dead 127.0.0.1:4179).
* So say what failed and name the single command that repairs it.
*/
function grokSyncFailureMessage(err: unknown): string {
const detail = err instanceof Error ? err.message : String(err);
return `Grok Build config sync failed: ${detail}. `
+ "~/.grok/config.toml may still point at a previous proxy port — "
+ "run 'ocx ensure' (or apply from the dashboard's Grok page) to repoint it.";
}
/** Argv for detached `start`, optionally hard-pinning the listen port. */
function startArgv(port?: number): string[] {
const args = [process.argv[1], "start"];
if (typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535) {
args.push("--port", String(Math.trunc(port)));
}
return args;
}
async function chooseListenPort(requestedPort?: number): Promise<number> {
const config = loadConfig();
const preferred = requestedPort ?? config.port ?? 10100;
const hardPin = requestedPort !== undefined && requestedPort > 0;
// Soft start: brief prefer-retry then ephemeral hop.
// Explicit `--port` (service wrappers / update restart): wait for the pinned port
// to free without killing any listener (healthy ocx / foreign). Never hop.
if (hardPin && preferred > 0) {
const { reclaimListenPort } = await import("../server/port-reclaim");
await reclaimListenPort(preferred, config.hostname ?? "127.0.0.1", {
// Ghost LISTEN rows with a dead PID can outlive the process for a while.
// SetTcpEntry(DELETE_TCB) needs elevation (often returns 317), so the only
// reliable non-admin recovery is to wait for the OS to release the TCB.
timeoutMs: 60_000,
intervalMs: 100,
scanIntervalMs: 500,
killOcxHolders: false,
dropTcpRows: true,
});
}
try {
const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1", {
// After reclaim, keep probing briefly — ghost rows sometimes clear between
// the reclaim deadline and the final listen. Still never hop off `--port`.
preferRetryMs: hardPin ? 5_000 : 750,
preferRetryIntervalMs: 50,
allowEphemeralFallback: !hardPin,
});
if (preferred > 0 && selected !== preferred) {
console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
}
if (shouldPersistSelectedPort(config.port, selected, preferred)) {
config.port = selected;
saveConfig(config);
}
return selected;
} catch (err) {
if (err instanceof PortUnavailableError) {
console.error(`❌ ${err.message}`);
console.error(" Stop whatever holds that port, or change config.port, then retry.");
process.exit(1);
}
throw err;
}
}
async function handleStart(options: { block?: boolean } = {}) {
// Native (WinSW) service mode has no batch wrapper to read the service token file
// into the environment, so the app loads it here before the server binds. The server
// auth path reads OPENCODEX_API_AUTH_TOKEN from the environment.
const serviceToken = loadServiceTokenFromFile(process.env);
if (serviceToken) process.env.OPENCODEX_API_AUTH_TOKEN = serviceToken;
const requestedPort = parsePortOption();
if (!currentExternalCodexModelProvider()) reconcileJournal();
const existingPid = readPid();
if (existingPid) {
const live = await findLiveProxy();
if (live) {
console.error(`⚠️ Proxy already running (PID ${live.pid ?? existingPid}, port ${live.port}). Use 'ocx stop' first.`);
process.exit(1);
}
removePid(existingPid);
}
// Interactive-only update prompt. Must run BEFORE we bind a port / write a
// PID: choosing "Update now" installs globally and exits, so we never want a
// live daemon holding resources while it overwrites its own binary.
await maybeShowUpdatePrompt();
// Port selection is check-then-bind: a concurrent `ocx start`/`ensure` can win the port
// between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries
// the same port only (never hop — that was the remaining PR #152 gap).
let port = await chooseListenPort(requestedPort);
let server: ReturnType<typeof startServer>;
for (let attempt = 0; ; attempt++) {
try {
server = startServer(port);
// Prewarm the live provider model cache as soon as the port is bound so the
// first GUI /v1/models (and syncModelsToCodex below) share one discovery flight
// instead of racing duplicate upstream /models fetches.
scheduleCatalogPrewarm();
break;
} catch (err) {
if (!isAddrInUse(err) || attempt >= 2) throw err;
if (requestedPort !== undefined) {
console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`);
const hostname = loadConfig().hostname ?? "127.0.0.1";
const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 3_000, intervalMs: 50 });
if (!freed) {
console.error(`❌ Port ${port} stayed busy; refusing to hop to an ephemeral port.`);
process.exit(1);
}
continue;
}
console.log(`⚠️ Port ${port} was taken while starting; picking another...`);
port = await chooseListenPort(requestedPort);
}
}
// A single request's streaming error must never crash the daemon serving every
// other Codex session — capture the full stack to crash.log and stay up.
installCrashGuards();
writePid(process.pid);
const config = loadConfig();
writeRuntimePort({ pid: process.pid, port, hostname: config.hostname });
// No pre-emptive snapshot here. `injectCodexConfig` journals the exact bytes it
// is about to transform; snapshotting earlier only captured a baseline that could
// already be stale by the time injection ran (#477).
// Background proactive token refresh. No-op unless config.tokenGuardian.enabled; timer is unref'd
// so it never keeps the process alive on its own. Stopped in syncCleanup so no refresh fires mid-drain.
const guardian = startTokenGuardian();
// Design B upgrade path: keep retrying the one-time opencodex→openai history migration in the
// background — the first `ocx start` after an update usually races the Codex app's DB lock.
// Loopback-only (legacy mode still forward-tags) and respects syncResumeHistory opt-out.
let historyGuardian: ReturnType<typeof startHistoryMigrationGuardian> | undefined;
let cleaned = false;
let cleanupSucceeded = true;
const syncCleanup = () => {
if (cleaned) return cleanupSucceeded;
cleaned = true;
try { guardian.stop(); } catch { /* best-effort */ }
try { historyGuardian?.stop(); } catch { /* best-effort */ }
// Dashboard drain-and-restart (#563) must not tear down injection: the replacement
// process expects Codex/Grok/env fences to still be in place.
const recycling = isRecyclingForExit();
if (!recycling) {
try { revertSystemEnv(); } catch { /* best-effort */ }
}
removePid(process.pid);
removeRuntimePort(process.pid);
if (!recycling && !process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) {
try {
const restored = restoreNativeCodex();
if (!restored.success) {
cleanupSucceeded = false;
console.error(`⚠️ Native Codex restore failed during shutdown: ${restored.message}`);
}
} catch (error) {
cleanupSucceeded = false;
console.error(`⚠️ Native Codex restore failed during shutdown: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Same ownership rule as `ocx stop`: if the installed service belongs to another home, the
// Grok fence is shared state we must not remove — that service keeps running and would be
// left pointing nowhere. This guard also covers signal-driven exits, which is the path that
// would otherwise bypass handleStop's gate entirely.
if (!recycling && !process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()) {
try { stripGrokConfig(); } catch { /* best-effort restore */ }
}
return cleanupSucceeded;
};
let shuttingDown = false;
let shutdownStartedAt = 0;
// Terminal Ctrl-C delivers SIGINT to the whole foreground group AND the launcher
// forwards its own — two signals land within milliseconds. Treat a duplicate inside
// this window as the same Ctrl-C (one graceful drain); a deliberate later press
// escalates to an immediate force-exit ("gradual kill").
const FORCE_AFTER_MS = 500;
const shutdown = () => {
const now = Date.now();
if (shuttingDown) {
if (now - shutdownStartedAt < FORCE_AFTER_MS) return; // near-simultaneous duplicate — ignore
console.log("\n⏹ Force shutdown (second signal).");
try { syncCleanup(); } catch { /* best-effort */ }
process.exit(130);
}
shuttingDown = true;
shutdownStartedAt = now;
console.log("\n🛑 Shutting down opencodex proxy...");
void (async () => {
try {
await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000);
} finally {
const restored = syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit")
process.exit(restored ? 0 : 1);
}
})();
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// The launcher (bin/ocx.mjs) forwards SIGHUP too (e.g. terminal close); handle it
// gracefully here so it drains + cleans up instead of a default immediate kill.
process.on("SIGHUP", shutdown);
process.on("exit", syncCleanup);
// System-wide env injection AFTER signal handlers are registered (crash safety:
// syncCleanup reverts even if injection itself or subsequent startup steps fail).
await injectSystemEnv(port, config).catch(() => {});
// Auto-install .zshrc hook (idempotent — skips if already present).
installShellHook();
await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
await syncModelsToCodex(port).catch(() => {});
if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) {
historyGuardian = startHistoryMigrationGuardian();
}
// Build Desktop 3P alias registry so inbound claude-opus-4-8-{code} aliases (and legacy claude-opus-4-{code}) decode correctly.
try {
const { fetchAllModels } = await import("../server/management-api");
const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../codex/catalog");
const models = filterCatalogVisibleModels(await fetchAllModels(config), config);
buildDesktop3pRegistry(
[...visibleNativeSlugs(config)],
models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })),
config.claudeCode?.desktopProfile,
);
} catch { /* best-effort — registry rebuilds on first /v1/models call */ }
// Grok Build auto-registration: additive fenced block in ~/.grok/config.toml so an installed
// grok CLI can pick opencodex-routed models without manual config. No-op when ~/.grok is
// absent or the bind is non-loopback; removed again by stop/eject/uninstall/shutdown.
// Deliberately a SIBLING of the Desktop-3P block above: nesting it there meant a catalog
// failure skipped the fence entirely, even though syncGrokConfig handles that case itself.
try {
const { syncGrokConfig } = await import("../grok/sync");
const r = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {});
if (r.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)");
else if (!r.ok) console.error(`⚠️ ${r.message}`);
} catch (err) {
// Best-effort: grok integration must never block startup. But swallowing the error
// silently is how a stale fence survives unnoticed — ~/.grok/config.toml keeps
// pointing at whatever port the LAST successful sync wrote, and if that listener is
// gone every grok turn retries against a refused connection with nothing in our log
// to explain it. Name the failure and the one command that repairs it.
console.error(`⚠️ ${grokSyncFailureMessage(err)}`);
}
if (options.block ?? true) {
setInterval(() => {}, 60_000);
await new Promise<void>(() => {});
}
}
async function handleEnsure() {
if (!currentExternalCodexModelProvider()) reconcileJournal();
const config = loadConfig();
if (!codexAutoStartEnabled(config)) {
console.log("Codex autostart is disabled.");
return;
}
const live = await findLiveProxy();
if (live) {
await syncModelsToCodex(live.port).catch(e => {
console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
});
// Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature).
await injectSystemEnv(live.port, config).catch(() => {});
// Refresh the Grok Build fence too (same contract as start). live.hostname is the
// hostname the running proxy actually bound — config.hostname may have drifted.
try {
const { syncGrokConfig } = await import("../grok/sync");
const g = await syncGrokConfig(live.port, config, live.hostname ? { hostname: live.hostname } : {});
if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)");
else if (!g.ok) console.error(`⚠️ ${g.message}`);
} catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); }
console.log(`✅ Proxy running on port ${live.port}`);
return;
}
const pinPort = config.port ?? 10100;
const child = spawn(process.execPath, startArgv(pinPort > 0 ? pinPort : undefined), {
detached: true,
stdio: "ignore",
windowsHide: true,
env: { ...process.env, OCX_SERVICE: "1" },
});
child.unref();
const port = (await waitForProxy())?.port;
if (!port) {
console.error("❌ Proxy did not become healthy after starting.");
process.exit(1);
}
// Deterministic fence guarantee: the spawned child injects late in its own startup, but
// this parent returns as soon as /healthz responds — inject here too (idempotent block
// replace) so `ocx ensure` never returns without the Grok fence in place.
try {
const { syncGrokConfig } = await import("../grok/sync");
const g = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {});
if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)");
else if (!g.ok) console.error(`⚠️ ${g.message}`);
} catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); }
// Always sync the LIVE port: after a fallback-port start, config.port still names the
// busy preferred port — syncing that would point Codex at a dead listener.
await syncModelsToCodex(port).catch(e => {
console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
});
console.log(`✅ Proxy running on port ${port}`);
}
/** Fixed tray action: start the proxy without depending on codexAutoStart. */
async function handleTrayProxyStart(): Promise<void> {
const ok = await runTrayProxyStart({
findLive: findLiveProxy,
diagnoseService: () => {
const service = diagnoseService();
return { installed: service.installed, startable: serviceStartableFromTray(service), summary: service.summary };
},
startService: () => serviceCommand("start"),
startDirect: () => {
const config = loadConfig();
const port = (config.port ?? 10100) > 0 ? (config.port ?? 10100) : 10100;
const child = spawn(process.execPath, startArgv(port), {
detached: true,
stdio: "ignore",
windowsHide: true,
env: { ...process.env, OCX_SERVICE: "1" },
});
child.unref();
},
waitForProxy,
info: message => console.log(message),
error: message => console.error(message),
});
if (!ok) process.exitCode = 1;
}
async function handleTrayProxyRestart(): Promise<void> {
const ok = await runTrayProxyRestart({
stop: async () => {
await handleStop();
return !process.exitCode || process.exitCode === 0;
},
start: async () => {
await handleTrayProxyStart();
return !process.exitCode || process.exitCode === 0;
},
});
if (!ok) process.exitCode = 1;
}
async function handleStop() {
let stopFailed = false;
let stoppedService = false;
// An ownership mismatch means the service manager was never even contacted: the installed
// service is still live and will respawn the proxy. Tearing down SHARED state in that
// situation (native Codex config, the Grok fence) removes config out from under a running
// service — the exact failure this flag prevents. A plain stop failure is different: we
// tried, so local teardown still proceeds.
let ownershipBlocked = false;
try {
stoppedService = stopServiceIfInstalled();
if (stoppedService) console.log("🛑 Service manager stopped (won't respawn).");
} catch (err) {
if (isServiceOwnershipError(err)) {
ownershipBlocked = true;
stopFailed = true;
console.error(`❌ ${err.message}`);
console.error(" Skipping shared teardown (native Codex restore, Grok config): the installed service is still running.");
} else {
console.error(`⚠️ Service manager stop failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
const pid = readPid();
if (pid) {
try {
// Graceful-first (management-API drain) — on Windows this is the only path where
// the proxy's shutdown handlers actually run; taskkill /F is the fallback inside.
await stopProxy(pid);
console.log(`✅ Proxy (PID ${pid}) stopped.`);
removePid(pid);
removeRuntimePort(pid);
} catch (err) {
stopFailed = true;
console.error(`❌ Failed to stop proxy (PID ${pid}).`);
// stopProxy throws with the reason — an ownership refusal (409) carries the
// remediation ("run the stop from that home"). Swallowing it leaves the operator
// with a bare failure and a manual `kill` as the obvious next move, which is the
// exact teardown the refusal exists to prevent.
const detail = err instanceof Error ? err.message : String(err);
if (detail) console.error(` ${detail}`);
}
} else {
// Snapshot the stale on-disk state BEFORE the async probe: a concurrent `ocx start`
// can write fresh records mid-probe, and the purge below must never delete those.
const stalePidValue = readPidFileValue();
const staleRuntimePid = readRuntimePort()?.pid ?? null;
// Orphan recovery: a live proxy can outlive its pid file (crash, manual delete,
// corrupt file). Identity-checked liveness still finds it via the runtime record.
const live = await findLiveProxy();
if (live?.pid) {
try {
await stopProxy(live.pid);
console.log(`✅ Proxy (PID ${live.pid}) stopped.`);
} catch (err) {
stopFailed = true;
console.error(`❌ Failed to stop proxy (PID ${live.pid}).`);
const detail = err instanceof Error ? err.message : String(err);
if (detail) console.error(` ${detail}`);
}
} else if (!stoppedService) {
console.log("No running proxy found.");
}
if (!stopFailed) {
// `readPid() === null` means the snapshotted pid file was absent, invalid, dead, or
// not ours — stale by definition. Purge (guarded by the snapshot) so `ocx update`'s
// stop gate can't wedge on it.
removePidIfValueIs(stalePidValue);
removeRuntimePortIfPidIs(staleRuntimePid);
}
}
if (!ownershipBlocked) {
const r = restoreNativeCodex();
if (r.success) console.log(`↩️ ${r.message}`);
else {
stopFailed = true;
console.error(`⚠️ ${r.message}`);
}
}
// revertSystemEnv is NOT gated: it carries its own ownership check and concerns launchctl
// user env, not CODEX_HOME. Safety net for when the daemon's syncCleanup didn't run (SIGKILL).
try { revertSystemEnv(); } catch { /* best-effort */ }
if (!ownershipBlocked) {
// Same safety net for the Grok Build managed block (marker-owned, idempotent).
try {
const g = stripGrokConfig();
if (g.changed) console.log(`↩️ ${g.message}`);
// A refused strip (e.g. orphaned marker) leaves the fence pointing at a dead proxy —
// reporting success there hides a broken end state.
else if (!g.ok) { stopFailed = true; console.error(`⚠️ ${g.message}`); }
} catch { /* best-effort */ }
}
// Set the code rather than exiting inline: `restart` and the tray coordinator call this
// function and need it to RETURN so they can decide what to do next.
if (stopFailed) process.exitCode = 1;
return !stopFailed;
}
async function handleUninstall() {
const failures: string[] = [];
const runStep = async (label: string, step: () => void | boolean | Promise<void | boolean>) => {
try {
const changed = await step();
if (changed === false) console.log(`- ${label}: not installed`);
else console.log(`✅ ${label}`);
} catch (err) {
failures.push(label);
console.error(`⚠️ ${label} failed: ${err instanceof Error ? err.message : String(err)}`);
}
};
await runStep("service stopped", () => stopServiceIfInstalled());
await runStep("proxy stopped", async () => {
const pid = readPid();
if (!pid) return false;
await stopProxy(pid);
removePid(pid);
removeRuntimePort(pid);
return true;
});
await runStep("service removed", () => uninstallServiceIfInstalled());
if (process.platform === "win32") {
await runStep("Windows tray removed", async () => {
const { getWindowsTrayStatus, uninstallWindowsTray } = await import("../tray/windows");
const tray = getWindowsTrayStatus();
if (!tray.installed && !tray.stale && !tray.running) return false;
uninstallWindowsTray();
});
}
await runStep("native Codex restored", () => {
const r = restoreNativeCodex();
if (!r.success) throw new Error(r.message);
});
await runStep("Grok Build config restored", () => {
const r = stripGrokConfig();
if (!r.ok) throw new Error(r.message);
return r.changed;
});
await runStep("system env vars reverted", () => {
const r = revertSystemEnv();
if (!r.reverted && r.reason !== "no tracking file" && r.reason !== "not macOS") throw new Error(r.reason ?? "revert failed");
});
await runStep("shell hook removed", () => {
const r = uninstallShellHook();
if (!r.removed && r.reason !== "not installed" && r.reason !== "not macOS") throw new Error(r.reason ?? "remove failed");
});
try {
const { uninstallCodexShim } = await import("../codex/shim");
const r = uninstallCodexShim();
console.log(r.removed ? "✅ Codex autostart shim removed" : "- Codex autostart shim removed: not installed");
} catch (err) {
failures.push("Codex autostart shim removed");
console.error(`⚠️ Codex autostart shim removed failed: ${err instanceof Error ? err.message : String(err)}`);
}
if (failures.length === 0) {
await runStep("opencodex config removed", () => {
const result = removeOwnedConfigState(getConfigDir());
if (result.status === "absent") return false;
if (result.status === "removed") return true;
const residual = result.residualPaths.length > 0
? ` Residual path(s): ${result.residualPaths.join(", ")}`
: "";
throw new Error(`${result.status} uninstall: ${result.reason ?? "config state was not removed"}.${residual}`);
});
} else {
console.error("Leaving opencodex config/backups in place so the failed restore step can be retried.");
}
if (failures.length > 0) {
console.error(`\nUninstall finished with ${failures.length} failed step(s): ${failures.join(", ")}`);
process.exit(1);
}
console.log("\n✅ opencodex local state removed. Remove the package with: npm uninstall -g @bitkyc08/opencodex");
}
async function handleStatus() {
const statusArgs = args.slice(1);
const wantsJson = statusArgs.length === 1 && statusArgs[0] === "--json";
if (statusArgs.length > 1 || (statusArgs.length === 1 && !wantsJson)) {
console.error("Usage: ocx status [--json]");
process.exit(1);
}
const status = await collectStatus();
if (wantsJson) {
console.log(JSON.stringify(status.json, null, 2));
return;
}
if (status.json.proxy.pid || status.json.proxy.health.ok) {
console.log(`✅ Proxy: ${status.proxyLabel}`);
} else {
console.log(`❌ Proxy: ${status.proxyLabel}`);
}
console.log(` Health: ${status.healthLabel}`);
if (!(status.json.proxy.pid || status.json.proxy.health.ok)) {
console.log(" ↳ Not running — Codex/Claude requests will fail with connection errors.");
console.log(" Restart with 'ocx start', or install the persistent service: 'ocx service install'.");
}
console.log(` Dashboard: ${status.json.dashboard.url}`);
console.log(` Config: ${status.json.paths.config}`);
console.log(` PID file: ${status.json.paths.pid}`);
console.log(` Runtime: ${status.json.paths.runtime}`);
console.log(` Runtime source: ${status.json.runtime.source}${status.json.runtime.overrideEnv ? ` (${status.json.runtime.overrideEnv})` : ""}`);
console.log(` Default provider: ${status.json.defaultProvider}`);
console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}`);
console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}`);
console.log(` Service: ${status.json.service.summary}`);
console.log(` ${status.json.codexShim.summary}`);
console.log(` Codex runtime: ${status.json.codexRuntime.path}`);
console.log(` Codex version: ${status.json.codexRuntime.version ?? "unknown"}`);
console.log(` Codex source: ${status.json.codexRuntime.source}`);
console.log(` Codex home: ${status.json.codexHome.effectiveCodexHome}`);
if (status.json.codexHome.warning) {
console.log(` ⚠️ ${status.json.codexHome.warning}`);
console.log(` Action: ${status.json.codexHome.action}`);
}
console.log(` Catalog clamp: ${status.json.codexRuntime.catalogClamp.active ? "active" : "inactive"}`);
if (status.json.codexRuntime.catalogClamp.removedEfforts.length > 0) {
console.log(` Removed efforts: ${status.json.codexRuntime.catalogClamp.removedEfforts.join(", ")}`);
}
if (status.json.codexRuntime.warning) {
console.log(` ⚠️ ${status.json.codexRuntime.warning}`);
}
if (status.json.codexPlugins.applicable) {
const icon = status.json.codexPlugins.stale ? "⚠️ " : "✅";
console.log(` ${icon} Codex bundled plugins: ${status.json.codexPlugins.summary}`);
if (status.json.codexPlugins.suggestedRepair) {
console.log(` Suggested: ${status.json.codexPlugins.suggestedRepair}`);
}
}
const { collectOAuthHealthEntriesForCli, oauthLoginSummary } = await import("../oauth");
const { formatOAuthHealthForStatus } = await import("./status-oauth");
console.log(` OAuth logins:`);
for (const e of oauthLoginSummary()) {
console.log(` ${e.provider.padEnd(10)} ${e.loggedIn ? `✓ logged in${e.email ? ` (${e.email})` : ""}` : "✗ not logged in"}`);
}
const oauthHealthBlock = formatOAuthHealthForStatus(await collectOAuthHealthEntriesForCli());
if (oauthHealthBlock) {
for (const line of oauthHealthBlock.split("\n")) {
console.log(` ${line}`);
}
}
}
function handleRecoverHistory() {
if (args[1] !== "--legacy-openai") {
console.error("Usage: ocx recover-history --legacy-openai");
console.error("Only use this if an older syncResumeHistory build already remapped OpenAI Codex App history to opencodex before backup support existed.");
process.exit(1);
}
const r = restoreLegacyOpenaiHistory();
if (r.failed) {
console.error(
"⚠️ Recovery SKIPPED: the Codex history DB is locked (Codex app/IDE open?). Close it and rerun this command.",
);
process.exit(1);
}
console.log(`Recovered ${r.rows} legacy thread(s) to openai (${r.files} rollout file(s) updated).`);
}
switch (command) {
case "init":
case "setup": {
const { runInit } = await import("./init");
await runInit();
break;
}
case "start":
await handleStart();
break;
case "stop": {
// Downtime warning lives HERE, not in handleStop: `restart`/tray-restart callers
// re-start the proxy immediately, so warning there would contradict the next line.
if (await handleStop()) {
console.log("⚠️ Codex/Claude requests through the proxy will fail until it is restarted ('ocx start' or 'ocx service start').");
}
break;
}
case "restore":
case "eject": {
if (args[1] === "back") {
// Reverse switch: re-point plain `codex` at the RUNNING proxy without touching its
// lifecycle — the counterpart of `ocx restore`. Start/stop triggers are unchanged;
// this only re-runs the same inject (config + catalog + history) `ocx start` does.
const live = await findLiveProxy();
if (!live) {
console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically.");
process.exit(1);
}
const synced = await syncModelsToCodex(live.port);
if (!synced.ok) {
process.exitCode = 1;
console.error("Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry.");
break;
}
const target = collectOrcaCodexHomeDiagnostic();
console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`);
break;
}
let r: { success: boolean; message: string };
try {
r = restoreNativeCodex();
} catch (err) {
r = { success: false, message: err instanceof Error ? err.message : String(err) };
}
if (r.success) console.log(`✅ ${r.message}`);
else {
console.error(`⚠️ ${r.message}`);
process.exitCode = 1;
}
try {
const g = stripGrokConfig();
if (g.changed) console.log(`✅ ${g.message}`);
else if (!g.ok) {
console.error(`⚠️ ${g.message}`);
process.exitCode = 1;
}
} catch { /* best-effort */ }
if (r.success) {
console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back");
} else {
console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex.");
}
break;
}
case "recover-history":
handleRecoverHistory();
break;
case "uninstall":
case "remove":
await handleUninstall();
break;
case "status":
await handleStatus();
break;
case "doctor": {
const { runDoctor } = await import("./doctor");
await runDoctor(args.slice(1));
break;
}
case "debug": {
const { handleDebugCommand } = await import("./debug");
await handleDebugCommand(args.slice(1));
break;
}
case "ensure":
await handleEnsure();
break;
case "login": {
const { handleLogin } = await import("../oauth/login-cli");
await handleLogin(args[1]);
break;
}
case "logout": {
const { removeCredential } = await import("../oauth/store");
const name = (args[1] ?? "").trim().toLowerCase();
await removeCredential(name);
console.log(`Logged out of ${name || "(none)"}.`);
break;
}
case "sync": {
const restartCodex = args.slice(1).includes("--restart-codex");
const synced = await syncModelsToCodex((await findLiveProxy())?.port);
if (!synced.ok) {
process.exitCode = 1;
console.error("Codex sync did not complete. Fix the reported Codex config issue and retry.");
}
// Only warn/restart when a catalog or models_cache write actually happened. This is
// deliberately not an `else`: refreshCodexModelCatalog runs before injectCodexConfig,
// so a sync can fail (`ok: false`) after the catalog was already rewritten — which is
// exactly when a long-lived app-server is holding the stale list.
if (synced.catalogWritten || synced.cacheSynced) {
const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes");
afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console });
}
break;
}
case "v2": {
const { cmdV2 } = await import("./v2");
process.exitCode = await cmdV2(args.slice(1), {}, async () => (await findLiveProxy())?.port);
break;
}
case "sync-cache": {
const restartCodex = args.slice(1).includes("--restart-codex");
const { invalidateCodexModelsCache } = await import("../codex/catalog");
// Only warn/restart when models_cache was actually rewritten from a readable catalog.
if (invalidateCodexModelsCache()) {
const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes");
afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console });
}
break;
}
case "gui": {
const cfg = await import("../config");
const config = cfg.loadConfig();
// Identity-checked liveness (not the pid file + a fixed sleep): finds a fallback-port
// proxy and waits until the spawned one actually answers before opening the browser.
let live = await findLiveProxy();
if (!live) {
console.log("Proxy not running. Starting...");
const child = spawn(process.execPath, startArgv((config.port ?? 10100) > 0 ? (config.port ?? 10100) : undefined), {
detached: true,
stdio: "ignore",
windowsHide: true,
env: process.env,
});
child.unref();
live = await waitForProxy();
if (!live) {
console.error("❌ Proxy did not become healthy after starting. Not opening the GUI.");
process.exit(1);
}
}
// Open the host the proxy actually binds — `localhost` only answers for
// loopback/wildcard binds, not a concrete LAN/IPv6 hostname.
const guiHost = probeHostname(live?.hostname ?? config.hostname);
const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`;
console.log(`Opening ${guiUrl}`);
const { openUrl } = await import("../lib/open-url");
openUrl(guiUrl);
break;
}
case "service":
await serviceCommand(...args.slice(1));
break;
case "tray": {
const { windowsTrayCommand } = await import("../tray/windows");
await windowsTrayCommand(args.slice(1));
break;
}
case "codex-shim": {
const { codexShimStatus, installCodexShim, uninstallCodexShim } = await import("../codex/shim");
switch (args[1]) {
case "install": {
const r = installCodexShim();
console.log(r.installed ? `✅ ${r.message}` : `⚠️ ${r.message}`);
break;
}
case "status":
console.log(codexShimStatus());
break;
case "uninstall":
case "remove": {
const r = uninstallCodexShim();
console.log(r.removed ? `✅ ${r.message}` : `⚠️ ${r.message}`);
break;
}
default:
console.error("Usage: ocx codex-shim <install|status|uninstall|remove>");
process.exit(1);
}
break;
}
case "update": {
// `ocx update --help` must print usage and exit WITHOUT side effects — running the
// real self-update stops the proxy and drops in-flight routed streams (issue #168).
if (hasHelpFlag(args.slice(1))) {
printSubcommandUsage("update");
break;
}
const { runUpdate } = await import("../update");
await runUpdate();
break;
}
case "__refresh-version": {
// Hidden, detached helper spawned by the update prompt to refresh the
// cached latest version without blocking the foreground start. Not in help.
const { refreshVersionCache } = await import("../update/notify");
const channel = args[1] === "preview" ? "preview" : "latest";
await refreshVersionCache(channel);
break;
}
case "__tray-start":
case "__tray-restart":
case "__startup-health":
await dispatchInternalCliCommand(command as InternalCliCommand, {
trayStart: handleTrayProxyStart,
trayRestart: handleTrayProxyRestart,
startupHealth: async () => {
const { collectStartupHealth } = await import("../codex/autostart-health");
console.log(JSON.stringify(collectStartupHealth(loadConfig())));
},
});
break;
case "__tray-host": {
const { runWindowsTrayHost } = await import("../tray/windows");
await runWindowsTrayHost();
break;
}
case "__gui-update-worker": {
const jobId = args[1];
if (!jobId) process.exit(1);
const channel = normalizeUpdateChannel(args[2]);
await runGuiUpdateWorker(jobId, channel, args[3] === "restart");
break;
}
case "restart": {
// A failed stop must not be followed by a re-inject: with a foreign service still running
// (ownership mismatch) we would rewrite shared config we just declined to touch.
if (await handleStop()) await handleEnsure();
else console.error("↩️ Restart aborted: the proxy was not stopped cleanly.");
break;
}
case "health": {
const healthArgs = args.slice(1);
const wantsHealthJson = healthArgs.includes("--json");
const live = await findLiveProxy();
if (wantsHealthJson) {
console.log(JSON.stringify({ ok: !!live, pid: live?.pid ?? null, port: live?.port ?? null }));
} else {
console.log(live ? `Proxy healthy (PID ${live.pid}, port ${live.port})` : "Proxy not healthy");
}
process.exit(live ? 0 : 1);
}
case "provider": {
const { handleProviderCommand } = await import("./provider");
await handleProviderCommand(args.slice(1));
break;
}
case "account": {
const { cmdAccount } = await import("./account");
process.exitCode = await cmdAccount(args.slice(1));
break;
}
case "models":
case "model": {
const { handleModels } = await import("./models");
await handleModels(args.slice(1));
break;
}
case "combo": {
const { handleComboCommand } = await import("./combo");
process.exitCode = await handleComboCommand(args.slice(1));
break;
}