@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.
Files changed (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -7,7 +7,8 @@ import {
7
7
  subagentDefaultSyncEffective,
8
8
  websocketsEnabled,
9
9
  } from "../config";
10
- import { withCodexWriteLock } from "./codex-write-lock";
10
+ import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock";
11
+ import { shouldSyncCodexOnStart } from "./desired-state";
11
12
  import { resolveCodexHistoryTransition } from "./history-transition";
12
13
  import {
13
14
  buildInjectWitness,
@@ -22,6 +23,7 @@ import {
22
23
  } from "./inject-coordination";
23
24
  import { readIntegrationRecord } from "./integration-record";
24
25
  import { classifyNativeRoutedResidue } from "./native-residue";
26
+ import { inspectNativeCodexOwnership } from "../integrations/native/ownership-preflight";
25
27
  import {
26
28
  resolveCodexCoordinatorDatabasePath,
27
29
  resolveEffectiveUserIdentity,
@@ -34,7 +36,7 @@ import {
34
36
  } from "./journal";
35
37
  import { withCatalogWriteSerialization } from "./catalog-write-serialization";
36
38
  import { restoreCodexCatalogWithPermit } from "./catalog/sync";
37
- import { syncCodexHistoryProvider } from "./history-provider";
39
+ import { syncCodexHistoryProvider, type CodexHistoryFailureReason } from "./history-provider";
38
40
  import {
39
41
  deriveCodexHistoryOperation,
40
42
  resolveCodexHistoryJobTarget,
@@ -187,8 +189,13 @@ export function providerBaseHost(hostname: string | undefined): string {
187
189
  }
188
190
 
189
191
  export function shouldInjectApiAuthHeader(
190
- config: Pick<OcxConfig, "hostname"> | undefined,
192
+ config: Pick<OcxConfig, "hostname" | "unauthenticatedLoopbackListener"> | undefined,
191
193
  ): boolean {
194
+ // The unauthenticated loopback listener is a loopback bind, so it admits without a
195
+ // credential (#1102). Emitting the env header anyway would be worse than useless: the
196
+ // directly-spawned app-server this exists for has no OPENCODEX_API_AUTH_TOKEN in its
197
+ // environment, and Codex would send an empty header value.
198
+ if (config?.unauthenticatedLoopbackListener?.enabled) return false;
192
199
  return !isLoopbackHostname(config?.hostname);
193
200
  }
194
201
 
@@ -618,6 +625,8 @@ export function chooseCatalogPathForInjection(
618
625
  export interface CodexInjectResult {
619
626
  success: boolean;
620
627
  message: string;
628
+ status?: "skipped";
629
+ skippedReason?: "desired_disabled" | "desired_enabled";
621
630
  nativeSubagentDefaultsWarning?: string;
622
631
  }
623
632
 
@@ -626,6 +635,17 @@ export async function injectCodexConfig(
626
635
  config?: OcxConfig,
627
636
  options: InjectCodexOptions = {},
628
637
  ): Promise<CodexInjectResult> {
638
+ // Point Codex at the unauthenticated loopback listener when it is enabled (#1102).
639
+ //
640
+ // Resolved here rather than at the call sites because every caller already passes the proxy
641
+ // port and the config together: startup sync, `ocx sync`, and the ensure path would each
642
+ // need the same two-line change, and a caller that missed it would silently emit a base_url
643
+ // requiring a credential the directly-spawned app-server does not have.
644
+ //
645
+ // The listener port is fixed in config, never OS-assigned, so this value survives restarts
646
+ // and matches what an already-running app-server read at startup.
647
+ const loopback = config?.unauthenticatedLoopbackListener;
648
+ if (loopback?.enabled) port = loopback.port;
629
649
  if (!existsSync(CODEX_CONFIG_PATH)) {
630
650
  return {
631
651
  success: false,
@@ -866,6 +886,14 @@ export async function injectCodexConfig(
866
886
  if (eligibility.kind === "legacy-uncoordinated") {
867
887
  // Unchanged behavior for homes the coordinator cannot yet adopt. Stated
868
888
  // rather than implied: this is the boundary, and adoption is its own phase.
889
+ if (!shouldSyncCodexOnStart(loadConfig())) {
890
+ return {
891
+ success: true,
892
+ status: "skipped",
893
+ skippedReason: "desired_disabled",
894
+ message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
895
+ };
896
+ }
869
897
  applyNativeArtifacts();
870
898
  } else {
871
899
  const coordinated = await withCodexWriteLock(
@@ -883,6 +911,9 @@ export async function injectCodexConfig(
883
911
  }),
884
912
  },
885
913
  (ctx) => {
914
+ if (!shouldSyncCodexOnStart(loadConfig())) {
915
+ throw new CodexWriteLockSkipped("desired_disabled");
916
+ }
886
917
  /*
887
918
  * Publish BEFORE touching the filesystem. `assertPublished` runs after this
888
919
  * callback returns and throws unless a transition was recorded, so writing
@@ -965,6 +996,7 @@ export async function injectCodexConfig(
965
996
  // handed down fixed; the Worker never takes a direction from its caller.
966
997
  const historyOutcome = await runCodexHistoryJob({
967
998
  ...resolveCodexHistoryJobTarget(),
999
+ expectedDesiredEnabled: true,
968
1000
  operation: deriveCodexHistoryOperation({
969
1001
  direction: "apply",
970
1002
  resumeHistory: config?.syncResumeHistory !== false,
@@ -1179,71 +1211,323 @@ export function removeCodexConfig(
1179
1211
  };
1180
1212
  }
1181
1213
 
1214
+ export type CodexRestoreArtifactState = "ok" | "skipped" | "failed";
1215
+
1216
+ export interface CodexRestoreConfigResult {
1217
+ state: CodexRestoreArtifactState;
1218
+ changed: boolean;
1219
+ action: "journal-restored" | "owned-fields-stripped" | "external-provider-preserved" | "failed";
1220
+ message: string;
1221
+ }
1222
+
1223
+ export interface CodexRestoreCatalogResult {
1224
+ state: CodexRestoreArtifactState;
1225
+ changed: boolean;
1226
+ removed: number;
1227
+ kept: number;
1228
+ path: string | null;
1229
+ message: string;
1230
+ }
1231
+
1232
+ export interface CodexRestoreHistoryResult {
1233
+ state: CodexRestoreArtifactState;
1234
+ changed: boolean;
1235
+ reason?: CodexHistoryFailureReason;
1236
+ rows: number;
1237
+ files: number;
1238
+ ejectedRows: number;
1239
+ message: string;
1240
+ }
1241
+
1242
+ export interface CodexNativeRestoreResult {
1243
+ success: boolean;
1244
+ message: string;
1245
+ externalProvider?: string;
1246
+ artifacts: {
1247
+ config: CodexRestoreConfigResult;
1248
+ catalog: CodexRestoreCatalogResult;
1249
+ history: CodexRestoreHistoryResult;
1250
+ };
1251
+ }
1252
+
1253
+ function failedHistoryRestore(reason?: CodexHistoryFailureReason): CodexRestoreHistoryResult {
1254
+ return {
1255
+ state: "failed",
1256
+ changed: false,
1257
+ ...(reason ? { reason } : {}),
1258
+ rows: 0,
1259
+ files: 0,
1260
+ ejectedRows: 0,
1261
+ message: reason === "permission"
1262
+ ? "Codex resume history could NOT be restored because permission was denied."
1263
+ : "Codex resume history could NOT be restored — the Codex app appears to be holding the history database.",
1264
+ };
1265
+ }
1266
+
1267
+ function externalProviderRestoreResult(activeProvider: string): CodexNativeRestoreResult {
1268
+ const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`;
1269
+ return {
1270
+ success: true,
1271
+ message,
1272
+ externalProvider: activeProvider,
1273
+ artifacts: {
1274
+ config: { state: "skipped", changed: false, action: "external-provider-preserved", message },
1275
+ catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
1276
+ history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
1277
+ },
1278
+ };
1279
+ }
1280
+
1281
+ /** A foreign service claim is an authority boundary, including explicit CLI restore. */
1282
+ function foreignOwnershipRestoreRefusal(message: string): CodexNativeRestoreResult {
1283
+ return {
1284
+ success: false,
1285
+ message: `Codex native restore refused: ${message}`,
1286
+ artifacts: {
1287
+ config: { state: "skipped", changed: false, action: "failed", message },
1288
+ catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
1289
+ history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
1290
+ },
1291
+ };
1292
+ }
1293
+
1294
+ function desiredEnabledRestoreSkip(): CodexNativeRestoreResult {
1295
+ const message = "Codex integration was re-enabled; native restore was skipped.";
1296
+ return skippedRestoreEnvelope(true, message);
1297
+ }
1298
+
1182
1299
  /**
1183
- * Recover native Codex: strip opencodex from config.toml AND drop proxy-routed catalog entries,
1184
- * so plain `codex` works when the proxy is stopped. Called by `ocx stop`, the proxy shutdown
1185
- * handler, and `ocx restore`. Idempotent + atomic.
1300
+ * A schema-complete all-skipped envelope for outcomes decided before any
1301
+ * restore machinery runs. Every `restore --json` path must stay shape-stable
1302
+ * with `CodexNativeRestoreResult`; consumers never special-case early exits.
1186
1303
  */
1304
+ export function skippedRestoreEnvelope(success: boolean, message: string): CodexNativeRestoreResult {
1305
+ return {
1306
+ success,
1307
+ message,
1308
+ artifacts: {
1309
+ config: { state: "skipped", changed: false, action: "owned-fields-stripped", message },
1310
+ catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
1311
+ history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
1312
+ },
1313
+ };
1314
+ }
1315
+
1316
+ /** The config/profile half of a native restore, reported as one artifact. */
1317
+ function restoreCodexConfigInline(): CodexRestoreConfigResult {
1318
+ try {
1319
+ const journal = restoreJournalState();
1320
+ const restored = journal.configRestored
1321
+ ? { success: true, message: "Codex config restored from opencodex journal." }
1322
+ : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
1323
+ return restored.success
1324
+ ? {
1325
+ state: "ok",
1326
+ changed: journal.configRestored || journal.profileRestored || journal.profileChanged || restored.message.startsWith("Removed"),
1327
+ action: journal.configRestored ? "journal-restored" : "owned-fields-stripped",
1328
+ message: restored.message,
1329
+ }
1330
+ : { state: "failed", changed: false, action: "failed", message: restored.message };
1331
+ } catch (error) {
1332
+ return { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) };
1333
+ }
1334
+ }
1335
+
1336
+ /** The catalog half, always inside its own K acquisition. */
1337
+ function restoreCodexCatalogArtifact(revalidateDesiredState: boolean): CodexRestoreCatalogResult {
1338
+ const owningCodexHome = getCodexHome();
1339
+ try {
1340
+ const restored = withCatalogWriteSerialization(owningCodexHome, permit =>
1341
+ revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())
1342
+ ? null
1343
+ : restoreCodexCatalogWithPermit(permit, owningCodexHome));
1344
+ return restored.kind === "completed" && restored.value !== null
1345
+ ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." }
1346
+ : restored.kind === "completed"
1347
+ ? {
1348
+ state: "skipped", changed: false, removed: 0, kept: 0, path: null,
1349
+ message: "Codex integration was re-enabled; native catalog restoration was skipped.",
1350
+ }
1351
+ : {
1352
+ state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH,
1353
+ message: `Codex catalog could not be restored: ${restored.reason}.`,
1354
+ };
1355
+ } catch (error) {
1356
+ return {
1357
+ state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH,
1358
+ message: error instanceof Error ? error.message : String(error),
1359
+ };
1360
+ }
1361
+ }
1362
+
1187
1363
  /**
1188
1364
  * Restore native Codex, running history in a Worker under H.
1189
1365
  *
1190
- * Prefer this everywhere. The synchronous variant below exists only for the
1191
- * process-exit path, where awaiting a thread is its own hazard.
1366
+ * On a coordinated home the config/profile restore happens INSIDE the Codex
1367
+ * write lock, publishing a `remove` transition the same serialization inject
1368
+ * uses. Without it, an older restore could overwrite a config a concurrent
1369
+ * enable had just written under the lock, and then honestly report success
1370
+ * while desired intent said ON. The desired-state re-read under the lock turns
1371
+ * that lost race into the discriminated `desired_enabled` skip.
1192
1372
  */
1193
- export async function restoreNativeCodexAsync(): Promise<{
1194
- success: boolean;
1195
- message: string;
1196
- }> {
1197
- const inline = restoreNativeCodex({ skipHistory: true });
1373
+ export async function restoreNativeCodexAsync(
1374
+ options: { revalidateDesiredState?: boolean } = {},
1375
+ ): Promise<CodexNativeRestoreResult> {
1376
+ const activeProvider = currentExternalCodexModelProvider();
1377
+ if (activeProvider) {
1378
+ // External-provider courtesy: only the stale journal is removed. The
1379
+ // history worker must not launch — it would turn a read-mostly courtesy
1380
+ // result into a history mutation on a home we do not own.
1381
+ removeJournal();
1382
+ return externalProviderRestoreResult(activeProvider);
1383
+ }
1384
+
1385
+ // `restore` normally honours a human request even when an unrelated
1386
+ // service-manager probe is unavailable. A recorded FOREIGN home is not an
1387
+ // unrelated probe: it is positive evidence another installation owns these
1388
+ // native artifacts, so do not create profile/claim locks before refusing.
1389
+ if (options.revalidateDesiredState) {
1390
+ const ownership = inspectNativeCodexOwnership();
1391
+ if (ownership.ownership === "foreign") return foreignOwnershipRestoreRefusal(ownership.reason);
1392
+ }
1393
+
1394
+ const eligibility = codexWriteCoordinationEligibility({
1395
+ coordinatorPath: () =>
1396
+ resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), getCodexHome()),
1397
+ residue: () => classifyNativeRoutedResidue(),
1398
+ integrationRecord: () => readIntegrationRecord(),
1399
+ });
1400
+
1401
+ let config: CodexRestoreConfigResult;
1402
+ let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
1403
+
1404
+ if (eligibility.kind === "coordinated") {
1405
+ // The restore has no candidate bytes to witness; freshness comes from the
1406
+ // filesystem reads and the desired-state re-read performed under the lock.
1407
+ const witness = { authoritySnapshotId: "codex-native-restore" };
1408
+ const coordinated = await withCodexWriteLock(
1409
+ {
1410
+ timeoutMs: DEFAULT_INJECT_LOCK_TIMEOUT_MS,
1411
+ admitted: witness,
1412
+ readAdmissionUnderLock: () => witness,
1413
+ },
1414
+ (ctx) => {
1415
+ if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
1416
+ throw new CodexWriteLockSkipped("desired_enabled");
1417
+ }
1418
+ const published = ctx.coordinator.beginTransition(
1419
+ {
1420
+ nativeGeneration: ctx.expectation.nativeBefore,
1421
+ currentTxId: ctx.currentTxId,
1422
+ },
1423
+ {
1424
+ txId: ctx.expectation.txId,
1425
+ direction: "remove",
1426
+ authoritySnapshotId: ctx.admission.authoritySnapshotId,
1427
+ nextRetryAt: new Date().toISOString(),
1428
+ },
1429
+ );
1430
+ if (published.kind !== "updated") {
1431
+ throw new CodexWriteConflictError(
1432
+ `The Codex transition could not be published: ${published.kind}.`,
1433
+ );
1434
+ }
1435
+ const preImages = captureCodexPreImages();
1436
+ let restored: CodexRestoreConfigResult;
1437
+ try {
1438
+ restored = restoreCodexConfigInline();
1439
+ } catch (error) {
1440
+ const compensated = restoreCodexPreImages(preImages);
1441
+ if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
1442
+ throw error;
1443
+ }
1444
+ return {
1445
+ config: restored,
1446
+ receipt: {
1447
+ nativeGeneration: ctx.expectation.nativeAfter,
1448
+ currentTxId: ctx.expectation.txId,
1449
+ },
1450
+ };
1451
+ },
1452
+ );
1453
+ if (coordinated.status === "skipped") return desiredEnabledRestoreSkip();
1454
+ if (coordinated.status !== "acquired") {
1455
+ config = {
1456
+ state: "failed",
1457
+ changed: false,
1458
+ action: "failed",
1459
+ message: coordinated.status === "busy"
1460
+ ? `Another process is writing Codex configuration right now (waited ${coordinated.waitedMs}ms). Retry shortly.`
1461
+ : `Codex configuration was not restored: ${coordinated.message}`,
1462
+ };
1463
+ } else {
1464
+ config = coordinated.value.config;
1465
+ transitionReceipt = coordinated.value.receipt;
1466
+ }
1467
+ } else {
1468
+ // Legacy-uncoordinated (or unresolvable) homes keep the unserialized path
1469
+ // they have always had; restore is the escape hatch and must not strand
1470
+ // them. The plain re-read still honors an intervening re-enable.
1471
+ if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
1472
+ return desiredEnabledRestoreSkip();
1473
+ }
1474
+ config = restoreCodexConfigInline();
1475
+ }
1476
+
1477
+ const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true);
1198
1478
  const outcome = await runCodexHistoryJob({
1199
1479
  ...resolveCodexHistoryJobTarget(),
1200
- operation: deriveCodexHistoryOperation({
1201
- direction: "restore",
1202
- // Restore always returns history to native when it runs at all; the
1203
- // opt-out belongs to apply, which is what put opencodex there.
1204
- resumeHistory: true,
1205
- legacyMode: false,
1206
- }),
1480
+ ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}),
1481
+ operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }),
1207
1482
  });
1208
- const historyMsg =
1209
- outcome.kind === "converged"
1210
- ? outcome.rows > 0
1211
- ? ` Resume history restored from opencodex backup (${outcome.rows} thread(s)).`
1212
- : ""
1213
- : outcome.kind === "skipped"
1214
- ? ""
1215
- : // A lock we could not take is reported, never counted as nothing to do.
1216
- ` ⚠️ Codex resume history could NOT be restored — the Codex app appears to be holding the history database. Close Codex and run \`ocx restore\` again.`;
1217
- return { success: inline.success, message: `${inline.message}${historyMsg}` };
1483
+ if (transitionReceipt) {
1484
+ resolveCodexHistoryTransition(transitionReceipt, outcome);
1485
+ }
1486
+ const history: CodexRestoreHistoryResult = outcome.kind === "converged"
1487
+ ? {
1488
+ state: "ok", changed: outcome.rows > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0,
1489
+ message: outcome.rows > 0
1490
+ ? `Resume history restored from opencodex backup (${outcome.rows} thread(s)).`
1491
+ : "Codex resume history was already native.",
1492
+ }
1493
+ : outcome.kind === "skipped"
1494
+ ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "Codex resume history was skipped." }
1495
+ : outcome.kind === "blocked" && (outcome.reason === "desired_disabled" || outcome.reason === "desired_enabled")
1496
+ ? {
1497
+ state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0,
1498
+ message: outcome.reason === "desired_disabled"
1499
+ ? "Codex integration was disabled; history restoration was skipped."
1500
+ : "Codex integration was enabled; history restoration was skipped.",
1501
+ }
1502
+ : outcome.kind === "blocked" && outcome.reason === "busy"
1503
+ ? failedHistoryRestore("busy")
1504
+ : outcome.kind === "failed"
1505
+ ? failedHistoryRestore(outcome.historyFailureReason)
1506
+ : failedHistoryRestore();
1507
+ const base = catalog.removed > 0
1508
+ ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).`
1509
+ : config.message;
1510
+ const success = config.state !== "failed"
1511
+ && catalog.state !== "failed"
1512
+ && history.state !== "failed";
1513
+ return {
1514
+ success,
1515
+ message: `${base}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`,
1516
+ artifacts: { config, catalog, history },
1517
+ };
1218
1518
  }
1219
1519
 
1220
- export function restoreNativeCodex(options: { skipHistory?: boolean } = {}): {
1221
- success: boolean;
1222
- message: string;
1223
- } {
1520
+ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateDesiredState?: boolean } = {}): CodexNativeRestoreResult {
1224
1521
  const activeProvider = currentExternalCodexModelProvider();
1225
1522
  if (activeProvider) {
1226
1523
  removeJournal();
1227
- return {
1228
- success: true,
1229
- message: `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`,
1230
- };
1524
+ return externalProviderRestoreResult(activeProvider);
1231
1525
  }
1232
- const journal = restoreJournalState();
1233
- const cfg = journal.configRestored
1234
- ? {
1235
- success: true,
1236
- message: "Codex config restored from opencodex journal.",
1237
- }
1238
- : removeCodexConfig({
1239
- preserveProfile: journal.profileRestored || journal.profileChanged,
1240
- });
1241
- const owningCodexHome = getCodexHome();
1242
- const restoredCatalog = withCatalogWriteSerialization(owningCodexHome, permit => restoreCodexCatalogWithPermit(permit, owningCodexHome));
1243
- const cat =
1244
- restoredCatalog.kind === "completed"
1245
- ? restoredCatalog.value
1246
- : { removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH };
1526
+ if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
1527
+ return desiredEnabledRestoreSkip();
1528
+ }
1529
+ const config = restoreCodexConfigInline();
1530
+ const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true);
1247
1531
  // Design B (loopback) steady state: threads are already tagged openai, so prove the
1248
1532
  // no-op with a readonly probe instead of write-opening a DB the Codex app may hold
1249
1533
  // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop).
@@ -1256,23 +1540,33 @@ export function restoreNativeCodex(options: { skipHistory?: boolean } = {}): {
1256
1540
  }
1257
1541
  // `skipHistory` is how the async wrapper takes this work for itself: the
1258
1542
  // native files come down here, and history runs in the Worker under H.
1259
- const history = options.skipHistory
1543
+ const rawHistory = options.skipHistory
1260
1544
  ? { rows: 0, files: 0 }
1261
1545
  : syncCodexHistoryProvider("openai", undefined, undefined, {
1262
1546
  skipWhenProvablyNoop,
1263
1547
  });
1264
- const msg =
1265
- cat.removed > 0
1266
- ? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
1267
- : cfg.message;
1268
- const historyMsg = history.failed
1269
- ? ` ⚠️ Codex resume history could NOT be restored — the Codex app appears to be holding the history DB. Close the Codex app/IDE and run 'ocx stop' again; until then routed threads stay hidden in the native app.`
1270
- : history.rows > 0
1271
- ? ` Resume history restored from opencodex backup (${history.rows} thread(s)).`
1272
- : history.ejectedRows
1273
- ? ` ${history.ejectedRows} opencodex history thread(s) were ejected to openai so native Codex can resume them.`
1274
- : "";
1275
- return { success: cfg.success, message: `${msg}${historyMsg}` };
1548
+ const history: CodexRestoreHistoryResult = options.skipHistory
1549
+ ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "History restoration runs asynchronously." }
1550
+ : rawHistory.failed
1551
+ ? failedHistoryRestore(rawHistory.failureReason)
1552
+ : {
1553
+ state: "ok",
1554
+ changed: rawHistory.rows > 0 || (rawHistory.ejectedRows ?? 0) > 0,
1555
+ rows: rawHistory.rows,
1556
+ files: rawHistory.files,
1557
+ ejectedRows: rawHistory.ejectedRows ?? 0,
1558
+ message: rawHistory.rows > 0
1559
+ ? `Resume history restored from opencodex backup (${rawHistory.rows} thread(s)).`
1560
+ : "Codex resume history was already native.",
1561
+ };
1562
+ const message = catalog.removed > 0
1563
+ ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).`
1564
+ : config.message;
1565
+ return {
1566
+ success: config.state !== "failed" && catalog.state !== "failed" && history.state !== "failed",
1567
+ message,
1568
+ artifacts: { config, catalog, history },
1569
+ };
1276
1570
  }
1277
1571
 
1278
1572
  export function getCodexConfigPath(): string {
@@ -74,7 +74,7 @@ export function writeHistoryProviderTransition(
74
74
  export function writeLegacyOpenaiHistoryRecovery(
75
75
  permit: HistoryWritePermit,
76
76
  target: HistoryWriteTarget,
77
- ): { rows: number; files: number; failed?: true } {
77
+ ): CodexHistorySyncResult {
78
78
  assertHistoryWritePermit(permit, target.canonicalStateDbPath);
79
79
  return restoreLegacyOpenaiHistory(target.canonicalStateDbPath);
80
80
  }
@@ -140,12 +140,16 @@ export function assertStableLockFile(path: string, handle: StableLockFile): void
140
140
  export async function hardenStableLockFile(
141
141
  path: string,
142
142
  platform: NodeJS.Platform = process.platform,
143
+ options: { retryTimedOutOnce?: boolean } = {},
143
144
  ): Promise<void> {
144
145
  if (platform === "win32") {
145
146
  // Best-effort here: POSIX modes are not authoritative on NTFS, and the
146
147
  // required ACL hardening below is what actually decides.
147
148
  try { chmodSync(path, 0o600); } catch { /* ACL below is authoritative. */ }
148
- await hardenSecretPathAsync(path, { required: true });
149
+ await hardenSecretPathAsync(path, {
150
+ required: true,
151
+ retryTimedOutOnce: options.retryTimedOutOnce,
152
+ });
149
153
  return;
150
154
  }
151
155
  // On POSIX the mode IS the mechanism, so a failure may not be swallowed.
@@ -53,6 +53,7 @@ interface OwnerEntry {
53
53
  timer?: ReturnType<typeof setTimeout>;
54
54
  drive?: Promise<void>;
55
55
  activeOperations: Set<Promise<unknown>>;
56
+ aclTimeoutRetryUsed: boolean;
56
57
  closing: boolean;
57
58
  }
58
59
 
@@ -149,8 +150,13 @@ async function prepareOwnerDatabase(entry: OwnerEntry): Promise<void> {
149
150
  // here still exercises the host's branch, and the production default — the
150
151
  // thing that actually hardens the owner's lock file — stays unproved. An
151
152
  // audit replaced this fallback with a no-op and 91 tests stayed green.
152
- await (entry.options.hardenPath
153
- ?? ((target: string) => hardenStableLockFile(target, entry.options.platform)))(entry.lockPath);
153
+ if (entry.options.hardenPath) {
154
+ await entry.options.hardenPath(entry.lockPath);
155
+ } else {
156
+ await hardenStableLockFile(entry.lockPath, entry.options.platform, {
157
+ retryTimedOutOnce: entry.aclTimeoutRetryUsed,
158
+ });
159
+ }
154
160
  assertStableLockFile(entry.lockPath, file);
155
161
  entry.file = file;
156
162
  file = undefined;
@@ -196,6 +202,13 @@ async function drive(entry: OwnerEntry, generation: number): Promise<void> {
196
202
  scheduleRetry(entry, generation);
197
203
  return;
198
204
  }
205
+ if (errorCode(error) === "ETIMEDOUT" && !entry.aclTimeoutRetryUsed) {
206
+ entry.aclTimeoutRetryUsed = true;
207
+ // A timeout is neither ownership contention nor a permanent ACL denial.
208
+ // Stay fail-closed in `acquiring` and spend exactly one fresh-budget retry.
209
+ scheduleRetry(entry, generation);
210
+ return;
211
+ }
199
212
  publish(entry, { status: "unavailable", homeId: entry.context.homeId, reason: "lock-unavailable" });
200
213
  }
201
214
  }
@@ -222,6 +235,7 @@ function entryFor(context: NativeProfileContext, options: NativeMainOwnerOptions
222
235
  snapshot: { status: "acquiring", homeId: context.homeId },
223
236
  listeners: new Set(),
224
237
  activeOperations: new Set(),
238
+ aclTimeoutRetryUsed: false,
225
239
  prepared: false,
226
240
  closing: false,
227
241
  };
@@ -239,7 +253,7 @@ export function retainNativeMainOwner(
239
253
  throw new NativeProfileError("NATIVE_MAIN_OWNER_BUSY", "Native-main ownership is closing.", 503, true);
240
254
  }
241
255
  entry.refs += 1;
242
- if (!entry.drive && !entry.database && !entry.timer) {
256
+ if (!entry.drive && !entry.database && !entry.timer && entry.snapshot.status !== "unavailable") {
243
257
  const generation = entry.generation;
244
258
  entry.drive = drive(entry, generation).finally(() => {
245
259
  if (entry.generation === generation) entry.drive = undefined;
@@ -913,6 +913,25 @@ export class NativeProfileManager {
913
913
  return { removed, live, cleanupFailed, plaintextMayRemain };
914
914
  }
915
915
 
916
+ /**
917
+ * Whether startup or the periodic cleaner has any stage state to inspect.
918
+ *
919
+ * Absence is the only lock-free result. Any entry or observation failure
920
+ * keeps the existing fail-closed sweep, so an unsafe/unreadable stage path
921
+ * can never be mistaken for an unused profile subsystem.
922
+ */
923
+ stageSweepRequired(): boolean {
924
+ for (const path of [this.context.stageRegistryPath, this.context.stagingRoot]) {
925
+ try {
926
+ lstatSync(path);
927
+ return true;
928
+ } catch (error) {
929
+ if (errorCode(error) !== "ENOENT") return true;
930
+ }
931
+ }
932
+ return false;
933
+ }
934
+
916
935
  async sweepStages(): Promise<NativeStageSweepResult> {
917
936
  return this.withLock(() => this.sweepStagesLocked());
918
937
  }
@@ -130,6 +130,14 @@ function ownerBlockedReason(owner: NativeMainOwnerSnapshot): "owner-conflict" |
130
130
 
131
131
  async function runOwnedStageSweep(entry: StartupEntry): Promise<boolean> {
132
132
  if (typeof (entry.manager as Partial<NativeProfileManager>).sweepStages !== "function") return true;
133
+ // A fresh installation has no stage registry or staging tree. Avoid creating
134
+ // and contending on the profile transaction database for an inert subsystem.
135
+ // Real managers treat every present or uncertain artifact as sweep-required;
136
+ // partial test/library managers without the preflight keep the old behavior.
137
+ if (
138
+ typeof (entry.manager as Partial<NativeProfileManager>).stageSweepRequired === "function"
139
+ && !entry.manager.stageSweepRequired()
140
+ ) return true;
133
141
  try {
134
142
  const result = await withNativeMainOwnerOperation(entry.manager.context, () => entry.manager.sweepStages());
135
143
  return !result.plaintextMayRemain;