@bitkyc08/opencodex 2.10.2 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- package/gui/dist/assets/index-Ca_3269W.css +0 -1
package/src/cli/index.ts
CHANGED
|
@@ -21,11 +21,21 @@ import {
|
|
|
21
21
|
} from "../config";
|
|
22
22
|
import { collectStatus } from "./status";
|
|
23
23
|
import { dispatchInternalCliCommand, type InternalCliCommand } from "./internal-dispatch";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
discoverStableProxyForRestart,
|
|
26
|
+
isProxyReplacement,
|
|
27
|
+
runProxyRestart,
|
|
28
|
+
runTrayProxyStart,
|
|
29
|
+
type ProxyRestartLive,
|
|
30
|
+
type ProxyRestartResult,
|
|
31
|
+
} from "./tray-proxy";
|
|
32
|
+
import { requestBoundSystemRestart } from "./system-restart-client";
|
|
25
33
|
import { installCrashGuards } from "../lib/crash-guard";
|
|
26
34
|
import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help";
|
|
27
35
|
import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
|
|
28
36
|
import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
|
|
37
|
+
import { createReadinessGate } from "../server/readiness";
|
|
38
|
+
import { parseReadyArgs, runReady, type ReadyArgs } from "./ready";
|
|
29
39
|
import { stopProxy } from "../lib/process-control";
|
|
30
40
|
import { loadServiceTokenFromFile } from "../lib/service-secrets";
|
|
31
41
|
import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
|
|
@@ -41,13 +51,14 @@ import { maybeShowStarPrompt } from "./star-prompt";
|
|
|
41
51
|
import { scheduleCatalogPrewarm } from "./catalog-prewarm";
|
|
42
52
|
import { maybeShowUpdatePrompt } from "../update/notify";
|
|
43
53
|
import { syncModelsToCodex } from "../codex/sync";
|
|
44
|
-
import { shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state";
|
|
54
|
+
import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state";
|
|
45
55
|
import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
|
|
46
56
|
import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
|
|
47
57
|
import { removeOwnedConfigState } from "../lib/config-ownership";
|
|
48
58
|
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
|
|
49
59
|
import { initializeNodeLauncherContext } from "./launcher-context";
|
|
50
60
|
import { createLocalAttestationSecret } from "../lib/local-management-attestation";
|
|
61
|
+
import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract";
|
|
51
62
|
|
|
52
63
|
initializeNodeLauncherContext();
|
|
53
64
|
const args = process.argv.slice(2);
|
|
@@ -69,6 +80,23 @@ if (command !== undefined && command !== "help" && hasHelpFlag(args.slice(1))) {
|
|
|
69
80
|
process.exit(0);
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
// P1: pre-parse `ocx ready` and reject invalid arguments with exit 64 BEFORE
|
|
84
|
+
// maybeAutoRestoreCodexShim (or any discovery/probe/filesystem-capable global
|
|
85
|
+
// preflight) runs. `ready --help` / `help ready` already exited above, so this
|
|
86
|
+
// only sees ready args without a help flag. Valid args are stashed so the
|
|
87
|
+
// switch dispatch can call runReady without a second parse.
|
|
88
|
+
let readyArgs: ReadyArgs | undefined;
|
|
89
|
+
if (command === "ready") {
|
|
90
|
+
const parsed = parseReadyArgs(args.slice(1));
|
|
91
|
+
if (!parsed.ok) {
|
|
92
|
+
console.error("Usage: ocx ready [--json] [--wait [--timeout <seconds>]]");
|
|
93
|
+
console.error(" --timeout requires --wait; <seconds> must be a positive integer (1..300).");
|
|
94
|
+
console.error(" Default wait timeout is 45 seconds.");
|
|
95
|
+
process.exit(parsed.code);
|
|
96
|
+
}
|
|
97
|
+
readyArgs = parsed.args;
|
|
98
|
+
}
|
|
99
|
+
|
|
72
100
|
maybeAutoRestoreCodexShim(command, args);
|
|
73
101
|
|
|
74
102
|
function parsePortOption(): number | undefined {
|
|
@@ -128,6 +156,18 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
|
|
|
128
156
|
const config = loadConfig();
|
|
129
157
|
const preferred = requestedPort ?? config.port ?? 10100;
|
|
130
158
|
const hardPin = requestedPort !== undefined && requestedPort > 0;
|
|
159
|
+
const reservedLoopbackPort = config.unauthenticatedLoopbackListener?.enabled
|
|
160
|
+
? config.unauthenticatedLoopbackListener.port
|
|
161
|
+
: undefined;
|
|
162
|
+
// Before the reclaim path, not after (#1102). Asking for the port the loopback listener is
|
|
163
|
+
// configured to bind is a configuration mistake, and reclaim would spend up to 60 seconds
|
|
164
|
+
// waiting for a socket to free before reporting "port is busy" — the wrong diagnosis for a
|
|
165
|
+
// collision the config can state outright.
|
|
166
|
+
if (reservedLoopbackPort !== undefined && preferred === reservedLoopbackPort) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Port ${preferred} is reserved for unauthenticatedLoopbackListener; choose a different proxy port.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
131
171
|
// Soft start: brief prefer-retry then ephemeral hop.
|
|
132
172
|
// Explicit `--port` (service wrappers / update restart): wait for the pinned port
|
|
133
173
|
// to free without killing any listener (healthy ocx / foreign). Never hop.
|
|
@@ -151,6 +191,11 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
|
|
|
151
191
|
preferRetryMs: hardPin ? 5_000 : 750,
|
|
152
192
|
preferRetryIntervalMs: 50,
|
|
153
193
|
allowEphemeralFallback: !hardPin,
|
|
194
|
+
// Never hand the public listener the port the loopback listener is configured to
|
|
195
|
+
// bind (#1102). Without this, `--port <loopback port>` binds the public listener
|
|
196
|
+
// first and the loopback bind then fails, rolling back a startup that was only
|
|
197
|
+
// ever a config collision.
|
|
198
|
+
...(reservedLoopbackPort !== undefined ? { reservedPort: reservedLoopbackPort } : {}),
|
|
154
199
|
});
|
|
155
200
|
if (preferred > 0 && selected !== preferred) {
|
|
156
201
|
console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
|
|
@@ -197,11 +242,16 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
197
242
|
// between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries
|
|
198
243
|
// the same port only (never hop — that was the remaining PR #152 gap).
|
|
199
244
|
let port = await chooseListenPort(requestedPort);
|
|
245
|
+
// One private readiness gate for this startServer invocation, captured by the
|
|
246
|
+
// listener's closure. handleStart owns it and transitions it after the
|
|
247
|
+
// post-startup sync settles. A second startServer in the same process would
|
|
248
|
+
// get its own gate and could never reset/mutate this one.
|
|
249
|
+
const readinessGate = createReadinessGate();
|
|
200
250
|
let server: ReturnType<typeof startServer>;
|
|
201
251
|
const localAttestationSecret = createLocalAttestationSecret();
|
|
202
252
|
for (let attempt = 0; ; attempt++) {
|
|
203
253
|
try {
|
|
204
|
-
server = startServer(port, { localAttestationSecret });
|
|
254
|
+
server = startServer(port, { localAttestationSecret, readinessGate });
|
|
205
255
|
// Prewarm the live provider model cache as soon as the port is bound so the
|
|
206
256
|
// first GUI /v1/models (and syncModelsToCodex below) share one discovery flight
|
|
207
257
|
// instead of racing duplicate upstream /models fetches.
|
|
@@ -257,7 +307,8 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
257
307
|
}
|
|
258
308
|
removePid(process.pid);
|
|
259
309
|
removeRuntimePort(process.pid);
|
|
260
|
-
|
|
310
|
+
const preserveRouting = process.env.OCX_SERVICE === "1";
|
|
311
|
+
if (!recycling && !preserveRouting && !currentExternalCodexModelProvider()) {
|
|
261
312
|
try {
|
|
262
313
|
const restored = restoreNativeCodex();
|
|
263
314
|
if (!restored.success) {
|
|
@@ -273,7 +324,7 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
273
324
|
// Grok fence is shared state we must not remove — that service keeps running and would be
|
|
274
325
|
// left pointing nowhere. This guard also covers signal-driven exits, which is the path that
|
|
275
326
|
// would otherwise bypass handleStop's gate entirely.
|
|
276
|
-
if (!recycling && !
|
|
327
|
+
if (!recycling && !preserveRouting && serviceEnvironmentOwnedHere()) {
|
|
277
328
|
try { stripGrokConfig(); } catch { /* best-effort restore */ }
|
|
278
329
|
}
|
|
279
330
|
return cleanupSucceeded;
|
|
@@ -321,7 +372,13 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
321
372
|
installShellHook();
|
|
322
373
|
|
|
323
374
|
await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
|
|
324
|
-
|
|
375
|
+
// Post-startup sync drives the readiness gate AND the #1046 stale app-server
|
|
376
|
+
// warning. `syncCodexOnStartIfEnabled` respects the Codex integration toggle
|
|
377
|
+
// (OFF → no sync) and reports whether anything was written; the readiness gate
|
|
378
|
+
// observes the real sync outcome (ok/warning) so /readyz never advertises a
|
|
379
|
+
// half-synced proxy as ready while /healthz stays live.
|
|
380
|
+
const startupSync = await syncCodexOnStartIfEnabled(port, config, undefined, readinessGate);
|
|
381
|
+
if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native.");
|
|
325
382
|
// #1046: one warning per startup, after BOTH writes. The server's cache
|
|
326
383
|
// invalidation happens first and the catalog sync second, so the mtime is only
|
|
327
384
|
// final here — and neither write site warns on its own, or a boot that hits
|
|
@@ -372,18 +429,32 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
372
429
|
}
|
|
373
430
|
}
|
|
374
431
|
|
|
375
|
-
|
|
432
|
+
function detachedStartEnvironment(): NodeJS.ProcessEnv {
|
|
433
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
434
|
+
// Only a real service wrapper may claim supervision. A detached ensure/tray child
|
|
435
|
+
// is an ordinary owner: while live it maintains routing, and on exit it restores it.
|
|
436
|
+
delete env.OCX_SERVICE;
|
|
437
|
+
return withProcessRuntimeProvenance(env);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Promise<boolean> {
|
|
376
441
|
if (!currentExternalCodexModelProvider()) reconcileJournal();
|
|
377
442
|
const config = loadConfig();
|
|
378
443
|
if (!codexAutoStartEnabled(config)) {
|
|
379
444
|
console.log("Codex autostart is disabled.");
|
|
380
|
-
return;
|
|
445
|
+
return false;
|
|
381
446
|
}
|
|
382
447
|
const live = await findLiveProxy();
|
|
383
|
-
|
|
384
|
-
|
|
448
|
+
if (live) {
|
|
449
|
+
if (options.existingIsSuccess === false) {
|
|
450
|
+
console.error("Proxy appeared while restart was confirming absence; no start was attempted.");
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
const synced = await syncModelsToCodex(live.port).catch(e => {
|
|
385
454
|
console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
455
|
+
return null;
|
|
386
456
|
});
|
|
457
|
+
if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
|
|
387
458
|
// Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature).
|
|
388
459
|
await injectSystemEnv(live.port, config).catch(() => {});
|
|
389
460
|
// Refresh the Grok Build fence too (same contract as start). live.hostname is the
|
|
@@ -395,7 +466,7 @@ async function handleEnsure() {
|
|
|
395
466
|
else if (!g.ok) console.error(`⚠️ ${g.message}`);
|
|
396
467
|
} catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); }
|
|
397
468
|
console.log(`✅ Proxy running on port ${live.port}`);
|
|
398
|
-
return;
|
|
469
|
+
return true;
|
|
399
470
|
}
|
|
400
471
|
|
|
401
472
|
const pinPort = config.port ?? 10100;
|
|
@@ -403,14 +474,15 @@ async function handleEnsure() {
|
|
|
403
474
|
detached: true,
|
|
404
475
|
stdio: "ignore",
|
|
405
476
|
windowsHide: true,
|
|
406
|
-
env:
|
|
477
|
+
env: detachedStartEnvironment(),
|
|
407
478
|
});
|
|
408
479
|
child.unref();
|
|
409
480
|
|
|
410
481
|
const port = (await waitForProxy())?.port;
|
|
411
482
|
if (!port) {
|
|
412
483
|
console.error("❌ Proxy did not become healthy after starting.");
|
|
413
|
-
process.
|
|
484
|
+
process.exitCode = 1;
|
|
485
|
+
return false;
|
|
414
486
|
}
|
|
415
487
|
// Deterministic fence guarantee: the spawned child injects late in its own startup, but
|
|
416
488
|
// this parent returns as soon as /healthz responds — inject here too (idempotent block
|
|
@@ -423,16 +495,20 @@ async function handleEnsure() {
|
|
|
423
495
|
} catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); }
|
|
424
496
|
// Always sync the LIVE port: after a fallback-port start, config.port still names the
|
|
425
497
|
// busy preferred port — syncing that would point Codex at a dead listener.
|
|
426
|
-
await syncModelsToCodex(port).catch(e => {
|
|
498
|
+
const synced = await syncModelsToCodex(port).catch(e => {
|
|
427
499
|
console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
500
|
+
return null;
|
|
428
501
|
});
|
|
502
|
+
if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
|
|
429
503
|
console.log(`✅ Proxy running on port ${port}`);
|
|
504
|
+
return true;
|
|
430
505
|
}
|
|
431
506
|
|
|
432
507
|
/** Fixed tray action: start the proxy without depending on codexAutoStart. */
|
|
433
|
-
async function handleTrayProxyStart(): Promise<
|
|
508
|
+
async function handleTrayProxyStart(existingIsSuccess = true): Promise<boolean> {
|
|
434
509
|
const ok = await runTrayProxyStart({
|
|
435
510
|
findLive: findLiveProxy,
|
|
511
|
+
existingIsSuccess,
|
|
436
512
|
diagnoseService: () => {
|
|
437
513
|
const service = diagnoseService();
|
|
438
514
|
return { installed: service.installed, startable: serviceStartableFromTray(service), summary: service.summary };
|
|
@@ -445,29 +521,117 @@ async function handleTrayProxyStart(): Promise<void> {
|
|
|
445
521
|
detached: true,
|
|
446
522
|
stdio: "ignore",
|
|
447
523
|
windowsHide: true,
|
|
448
|
-
env:
|
|
524
|
+
env: detachedStartEnvironment(),
|
|
449
525
|
});
|
|
450
526
|
child.unref();
|
|
451
527
|
},
|
|
452
|
-
|
|
528
|
+
// serviceCommand("start") already spends up to 20s confirming the supervised
|
|
529
|
+
// child. Slow Windows hosts can still be publishing native-main state after that
|
|
530
|
+
// first window, so keep one shared follow-up budget instead of returning a false
|
|
531
|
+
// failure while Task Scheduler is still starting the approved child.
|
|
532
|
+
waitForProxy: () => waitForProxy(40_000),
|
|
453
533
|
info: message => console.log(message),
|
|
454
534
|
error: message => console.error(message),
|
|
455
535
|
});
|
|
456
|
-
|
|
536
|
+
// serviceCommand("start") can set exitCode=1 after its own 20s probe, while
|
|
537
|
+
// the coordinator's bounded follow-up observes the same service become live.
|
|
538
|
+
// The final observed state, not the earlier probe, owns this command result.
|
|
539
|
+
process.exitCode = ok ? 0 : 1;
|
|
540
|
+
return ok;
|
|
457
541
|
}
|
|
458
542
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
543
|
+
const PROXY_RESTART_OBSERVE_MS = MEMORY_DRAIN_RESTART_MS + REPLACEMENT_READY_TIMEOUT_MS + 15_000;
|
|
544
|
+
|
|
545
|
+
async function waitForProxyReplacement(
|
|
546
|
+
previous: ProxyRestartLive,
|
|
547
|
+
deadlineAt: number,
|
|
548
|
+
): Promise<ProxyRestartLive | null> {
|
|
549
|
+
while (Date.now() < deadlineAt) {
|
|
550
|
+
const live = await findLiveProxy({ deadlineAt });
|
|
551
|
+
if (Date.now() >= deadlineAt) return null;
|
|
552
|
+
// Modern /healthz publishes a PID. Require a different, identity-verified process;
|
|
553
|
+
// merely seeing the old port online again is not proof that restart completed.
|
|
554
|
+
if (isProxyReplacement(previous, live)) {
|
|
555
|
+
return live;
|
|
556
|
+
}
|
|
557
|
+
const remainingMs = deadlineAt - Date.now();
|
|
558
|
+
if (remainingMs > 0) await Bun.sleep(Math.min(250, remainingMs));
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function reportRestartFailure(result: Extract<ProxyRestartResult, { ok: false }>): void {
|
|
564
|
+
if (result.phase === "identity") {
|
|
565
|
+
console.error("❌ Refusing to restart because the running proxy identity could not be attested.");
|
|
566
|
+
} else if (result.phase === "request") {
|
|
567
|
+
const code = result.error instanceof Error ? result.error.message : "";
|
|
568
|
+
if (code === "restart_capability_unsupported") {
|
|
569
|
+
console.error("❌ The running proxy predates process-bound restart support; no unsafe fallback was attempted.");
|
|
570
|
+
console.error(" After confirming this home owns the proxy, run `ocx stop` and then `ocx start` once.");
|
|
571
|
+
} else {
|
|
572
|
+
console.error("❌ Proxy restart request could not be confirmed; no fallback stop/start was attempted.");
|
|
573
|
+
}
|
|
574
|
+
} else if (result.phase === "replacement") {
|
|
575
|
+
console.error("❌ Proxy restart was accepted, but no identity-verified replacement became healthy in time.");
|
|
576
|
+
} else {
|
|
577
|
+
console.error("❌ Proxy was not running and the fallback start did not become healthy.");
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function handleProxyRestart(
|
|
582
|
+
startWhenStopped: () => Promise<boolean | "skipped">,
|
|
583
|
+
): Promise<boolean> {
|
|
584
|
+
const deadlineAt = Date.now() + PROXY_RESTART_OBSERVE_MS;
|
|
585
|
+
const result = await runProxyRestart({
|
|
586
|
+
findLive: () => discoverStableProxyForRestart({
|
|
587
|
+
findLive: () => findLiveProxy({ deadlineAt, attempts: 2 }),
|
|
588
|
+
expired: () => Date.now() >= deadlineAt,
|
|
589
|
+
}),
|
|
590
|
+
startWhenStopped,
|
|
591
|
+
requestInPlaceRestart: previous => requestBoundSystemRestart(previous, deadlineAt),
|
|
592
|
+
waitForReplacement: previous => waitForProxyReplacement(previous, deadlineAt),
|
|
469
593
|
});
|
|
470
|
-
if (!ok)
|
|
594
|
+
if (!result.ok) reportRestartFailure(result);
|
|
595
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
596
|
+
return result.ok;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function handleTrayProxyRestart(): Promise<void> {
|
|
600
|
+
await handleProxyRestart(() => handleTrayProxyStart(false));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function handleRestartStartWhenStopped(): Promise<boolean | "skipped"> {
|
|
604
|
+
if (!codexAutoStartEnabled(loadConfig())) {
|
|
605
|
+
console.log("Codex autostart is disabled; no proxy was started.");
|
|
606
|
+
return "skipped";
|
|
607
|
+
}
|
|
608
|
+
return handleEnsure({ existingIsSuccess: false });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function restoreSharedClientStateAfterStop(): Promise<boolean> {
|
|
612
|
+
let restored = true;
|
|
613
|
+
try {
|
|
614
|
+
const result = await restoreNativeCodexAsync();
|
|
615
|
+
if (result.success) console.log(`↩️ ${result.message}`);
|
|
616
|
+
else {
|
|
617
|
+
restored = false;
|
|
618
|
+
console.error(`⚠️ ${result.message}`);
|
|
619
|
+
}
|
|
620
|
+
} catch (error) {
|
|
621
|
+
restored = false;
|
|
622
|
+
console.error(`⚠️ Native Codex restore failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// A refused or thrown Grok strip is actionable because it would point Grok at a dead proxy.
|
|
626
|
+
try {
|
|
627
|
+
const grok = stripGrokConfig();
|
|
628
|
+
if (grok.changed) console.log(`↩️ ${grok.message}`);
|
|
629
|
+
else if (!grok.ok) { restored = false; console.error(`⚠️ ${grok.message}`); }
|
|
630
|
+
} catch (error) {
|
|
631
|
+
restored = false;
|
|
632
|
+
console.error(`⚠️ Grok config restore failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
633
|
+
}
|
|
634
|
+
return restored;
|
|
471
635
|
}
|
|
472
636
|
|
|
473
637
|
async function handleStop() {
|
|
@@ -541,26 +705,11 @@ async function handleStop() {
|
|
|
541
705
|
removeRuntimePortIfPidIs(staleRuntimePid);
|
|
542
706
|
}
|
|
543
707
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (r.success) console.log(`↩️ ${r.message}`);
|
|
547
|
-
else {
|
|
548
|
-
stopFailed = true;
|
|
549
|
-
console.error(`⚠️ ${r.message}`);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
// revertSystemEnv is NOT gated: it carries its own ownership check and concerns launchctl
|
|
553
|
-
// user env, not CODEX_HOME. Safety net for when the daemon's syncCleanup didn't run (SIGKILL).
|
|
708
|
+
// Environment ownership is independent from service ownership. Always roll back
|
|
709
|
+
// current-home variables; the helper refuses foreign markers on its own.
|
|
554
710
|
try { revertSystemEnv(); } catch { /* best-effort */ }
|
|
555
711
|
if (!ownershipBlocked) {
|
|
556
|
-
|
|
557
|
-
try {
|
|
558
|
-
const g = stripGrokConfig();
|
|
559
|
-
if (g.changed) console.log(`↩️ ${g.message}`);
|
|
560
|
-
// A refused strip (e.g. orphaned marker) leaves the fence pointing at a dead proxy —
|
|
561
|
-
// reporting success there hides a broken end state.
|
|
562
|
-
else if (!g.ok) { stopFailed = true; console.error(`⚠️ ${g.message}`); }
|
|
563
|
-
} catch { /* best-effort */ }
|
|
712
|
+
if (!await restoreSharedClientStateAfterStop()) stopFailed = true;
|
|
564
713
|
}
|
|
565
714
|
// Set the code rather than exiting inline: `restart` and the tray coordinator call this
|
|
566
715
|
// function and need it to RETURN so they can decide what to do next.
|
|
@@ -757,6 +906,17 @@ async function handleRecoverHistory() {
|
|
|
757
906
|
console.log(`Recovered ${r.rows} legacy thread(s) to openai (${r.files} rollout file(s) updated).`);
|
|
758
907
|
}
|
|
759
908
|
|
|
909
|
+
/**
|
|
910
|
+
* `ocx ready` — arguments are pre-parsed above (before
|
|
911
|
+
* maybeAutoRestoreCodexShim) so invalid usage exits 64 before any global
|
|
912
|
+
* preflight. This handler only runs the dependency-injected runner in ./ready
|
|
913
|
+
* and exits with the returned code; it performs no parsing and no I/O of its
|
|
914
|
+
* own. The full behavior is unit-testable without spawning a subprocess.
|
|
915
|
+
*/
|
|
916
|
+
async function handleReady(args: ReadyArgs): Promise<never> {
|
|
917
|
+
process.exit(await runReady(args));
|
|
918
|
+
}
|
|
919
|
+
|
|
760
920
|
switch (command) {
|
|
761
921
|
case "init":
|
|
762
922
|
case "setup": {
|
|
@@ -777,6 +937,7 @@ switch (command) {
|
|
|
777
937
|
}
|
|
778
938
|
case "restore":
|
|
779
939
|
case "eject": {
|
|
940
|
+
const restoreJson = args[1] === "--json";
|
|
780
941
|
if (args[1] === "back") {
|
|
781
942
|
// Reverse switch: re-point plain `codex` at the RUNNING proxy without touching its
|
|
782
943
|
// lifecycle — the counterpart of `ocx restore`. Start/stop triggers are unchanged;
|
|
@@ -786,7 +947,18 @@ switch (command) {
|
|
|
786
947
|
console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically.");
|
|
787
948
|
process.exit(1);
|
|
788
949
|
}
|
|
950
|
+
const desired = setIntegrationEnabled("codex", true);
|
|
951
|
+
if (!desired.ok) {
|
|
952
|
+
process.exitCode = desired.reason === "conflict" ? 2 : 1;
|
|
953
|
+
console.error(`Codex desired state was not saved (${desired.reason}).`);
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
789
956
|
const synced = await syncModelsToCodex(live.port);
|
|
957
|
+
if (synced.status === "skipped") {
|
|
958
|
+
process.exitCode = 2;
|
|
959
|
+
console.error("Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes.");
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
790
962
|
if (!synced.ok) {
|
|
791
963
|
process.exitCode = 1;
|
|
792
964
|
console.error("Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry.");
|
|
@@ -796,12 +968,49 @@ switch (command) {
|
|
|
796
968
|
console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`);
|
|
797
969
|
break;
|
|
798
970
|
}
|
|
971
|
+
const desired = setIntegrationEnabled("codex", false);
|
|
972
|
+
if (!desired.ok) {
|
|
973
|
+
process.exitCode = desired.reason === "conflict" ? 2 : 1;
|
|
974
|
+
if (restoreJson) {
|
|
975
|
+
// Machine-readable contract: every restore --json outcome emits one
|
|
976
|
+
// schema-complete envelope on stdout, including pre-machinery failures.
|
|
977
|
+
const { skippedRestoreEnvelope } = await import("../codex/inject");
|
|
978
|
+
console.log(JSON.stringify(skippedRestoreEnvelope(false, `Codex desired state was not saved (${desired.reason}).`)));
|
|
979
|
+
} else {
|
|
980
|
+
console.error(`Codex desired state was not saved (${desired.reason}).`);
|
|
981
|
+
}
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
// A repeated OFF on an already-clean home is a policy no-op. Do not enter
|
|
985
|
+
// restore's native-profile machinery merely to prove there is nothing to
|
|
986
|
+
// restore: those locks live in CODEX_HOME and a skip must create nothing.
|
|
987
|
+
if (desired.status === "unchanged") {
|
|
988
|
+
const { classifyNativeRoutedResidue } = await import("../codex/native-residue");
|
|
989
|
+
if (classifyNativeRoutedResidue().kind === "clean") {
|
|
990
|
+
const alreadyOff = "Codex integration is already OFF and native; no Codex files changed.";
|
|
991
|
+
if (restoreJson) {
|
|
992
|
+
const { skippedRestoreEnvelope } = await import("../codex/inject");
|
|
993
|
+
console.log(JSON.stringify(skippedRestoreEnvelope(true, alreadyOff)));
|
|
994
|
+
} else {
|
|
995
|
+
console.log(alreadyOff);
|
|
996
|
+
}
|
|
997
|
+
break;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
799
1000
|
let r: { success: boolean; message: string };
|
|
800
1001
|
try {
|
|
801
|
-
r = await restoreNativeCodexAsync();
|
|
1002
|
+
r = await restoreNativeCodexAsync({ revalidateDesiredState: true });
|
|
802
1003
|
} catch (err) {
|
|
803
1004
|
r = { success: false, message: err instanceof Error ? err.message : String(err) };
|
|
804
1005
|
}
|
|
1006
|
+
if (restoreJson) {
|
|
1007
|
+
// Spawned callers need the artifact-level result to distinguish a busy
|
|
1008
|
+
// history worker from a successful native restore. Keep stdout machine
|
|
1009
|
+
// readable; human framing remains the default command contract.
|
|
1010
|
+
console.log(JSON.stringify(r));
|
|
1011
|
+
if (!r.success) process.exitCode = 1;
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
805
1014
|
if (r.success) console.log(`✅ ${r.message}`);
|
|
806
1015
|
else {
|
|
807
1016
|
console.error(`⚠️ ${r.message}`);
|
|
@@ -816,7 +1025,7 @@ switch (command) {
|
|
|
816
1025
|
}
|
|
817
1026
|
} catch { /* best-effort */ }
|
|
818
1027
|
if (r.success) {
|
|
819
|
-
console.log("
|
|
1028
|
+
console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back");
|
|
820
1029
|
} else {
|
|
821
1030
|
console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex.");
|
|
822
1031
|
}
|
|
@@ -860,7 +1069,9 @@ switch (command) {
|
|
|
860
1069
|
case "sync": {
|
|
861
1070
|
const restartCodex = args.slice(1).includes("--restart-codex");
|
|
862
1071
|
const synced = await syncModelsToCodex((await findLiveProxy())?.port);
|
|
863
|
-
if (
|
|
1072
|
+
if (synced.status === "skipped") {
|
|
1073
|
+
console.log("Codex integration is OFF; sync skipped and no Codex files changed.");
|
|
1074
|
+
} else if (!synced.ok) {
|
|
864
1075
|
process.exitCode = 1;
|
|
865
1076
|
console.error("Codex sync did not complete. Fix the reported Codex config issue and retry.");
|
|
866
1077
|
}
|
|
@@ -881,6 +1092,10 @@ switch (command) {
|
|
|
881
1092
|
}
|
|
882
1093
|
case "sync-cache": {
|
|
883
1094
|
const restartCodex = args.slice(1).includes("--restart-codex");
|
|
1095
|
+
if (!shouldSyncCodexOnStart(loadConfig())) {
|
|
1096
|
+
console.log("Codex integration is OFF; cache sync skipped and no Codex files changed.");
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
884
1099
|
const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization");
|
|
885
1100
|
const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync");
|
|
886
1101
|
const { getCodexHome } = await import("../codex/paths");
|
|
@@ -978,7 +1193,7 @@ switch (command) {
|
|
|
978
1193
|
case "__tray-restart":
|
|
979
1194
|
case "__startup-health":
|
|
980
1195
|
await dispatchInternalCliCommand(command as InternalCliCommand, {
|
|
981
|
-
trayStart: handleTrayProxyStart,
|
|
1196
|
+
trayStart: async () => { await handleTrayProxyStart(); },
|
|
982
1197
|
trayRestart: handleTrayProxyRestart,
|
|
983
1198
|
startupHealth: async () => {
|
|
984
1199
|
const { collectStartupHealth } = await import("../codex/autostart-health");
|
|
@@ -999,10 +1214,9 @@ switch (command) {
|
|
|
999
1214
|
break;
|
|
1000
1215
|
}
|
|
1001
1216
|
case "restart": {
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1005
|
-
else console.error("↩️ Restart aborted: the proxy was not stopped cleanly.");
|
|
1217
|
+
// The running proxy owns its drain and replacement through /api/system/restart.
|
|
1218
|
+
// If nothing is live, restart degrades to the documented `ensure` start behavior.
|
|
1219
|
+
await handleProxyRestart(handleRestartStartWhenStopped);
|
|
1006
1220
|
break;
|
|
1007
1221
|
}
|
|
1008
1222
|
case "health": {
|
|
@@ -1016,6 +1230,14 @@ switch (command) {
|
|
|
1016
1230
|
}
|
|
1017
1231
|
process.exit(live ? 0 : 1);
|
|
1018
1232
|
}
|
|
1233
|
+
case "ready":
|
|
1234
|
+
// Fail-closed impossible-state guard: readyArgs is populated by the
|
|
1235
|
+
// preparse block before maybeAutoRestoreCodexShim, so reaching here
|
|
1236
|
+
// without it means dispatch diverged. Refuse with code 64 and perform
|
|
1237
|
+
// NO I/O (no discovery/probe). process.exit is `never`, narrowing below.
|
|
1238
|
+
if (!readyArgs) process.exit(64);
|
|
1239
|
+
await handleReady(readyArgs);
|
|
1240
|
+
break;
|
|
1019
1241
|
case "provider": {
|
|
1020
1242
|
const { handleProviderCommand } = await import("./provider");
|
|
1021
1243
|
await handleProviderCommand(args.slice(1));
|
package/src/cli/models.ts
CHANGED
|
@@ -102,9 +102,13 @@ function rejectUnexpectedArgs(args: string[], usage: string): void {
|
|
|
102
102
|
async function syncCustomModelsIfLive(): Promise<void> {
|
|
103
103
|
const live = await findLiveProxy();
|
|
104
104
|
if (!live) return;
|
|
105
|
-
await syncModelsToCodex(live.port).catch(error => {
|
|
105
|
+
const synced = await syncModelsToCodex(live.port).catch(error => {
|
|
106
106
|
console.error(`Warning: custom model saved, but catalog sync failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
|
+
return null;
|
|
107
108
|
});
|
|
109
|
+
if (synced?.status === "skipped") {
|
|
110
|
+
console.log("Custom model saved; Codex integration is OFF, so its catalog was not changed.");
|
|
111
|
+
}
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
async function handleCustomAdd(args: string[]): Promise<void> {
|
package/src/cli/provider.ts
CHANGED
|
@@ -229,12 +229,18 @@ async function handleAdd(args: string[]): Promise<void> {
|
|
|
229
229
|
return;
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
let codexSyncSkipped = false;
|
|
232
233
|
if (wantsSync) {
|
|
233
234
|
const live = await findLiveProxy();
|
|
234
235
|
if (live) {
|
|
235
|
-
await syncModelsToCodex(live.port).catch(e => {
|
|
236
|
+
const synced = await syncModelsToCodex(live.port).catch(e => {
|
|
236
237
|
console.error(`Warning: sync failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
238
|
+
return null;
|
|
237
239
|
});
|
|
240
|
+
if (synced?.status === "skipped") {
|
|
241
|
+
codexSyncSkipped = true;
|
|
242
|
+
console.log("Provider saved; Codex integration is OFF, so Codex sync was skipped.");
|
|
243
|
+
}
|
|
238
244
|
}
|
|
239
245
|
}
|
|
240
246
|
|
|
@@ -249,7 +255,7 @@ async function handleAdd(args: string[]): Promise<void> {
|
|
|
249
255
|
console.log(` Set API key with: ocx provider add ${name} --api-key <key> --force`);
|
|
250
256
|
console.log(` Or set env var: ${envKey}`);
|
|
251
257
|
}
|
|
252
|
-
if (wantsSync) {
|
|
258
|
+
if (wantsSync && !codexSyncSkipped) {
|
|
253
259
|
console.log(` Models synced to Codex.`);
|
|
254
260
|
} else {
|
|
255
261
|
console.log(` Apply to Codex: ocx sync`);
|