@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/codex/inject.ts
CHANGED
|
@@ -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
|
-
*
|
|
1184
|
-
*
|
|
1185
|
-
*
|
|
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
|
-
*
|
|
1191
|
-
*
|
|
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(
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
1209
|
-
outcome
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
|
1543
|
+
const rawHistory = options.skipHistory
|
|
1260
1544
|
? { rows: 0, files: 0 }
|
|
1261
1545
|
: syncCodexHistoryProvider("openai", undefined, undefined, {
|
|
1262
1546
|
skipWhenProvablyNoop,
|
|
1263
1547
|
});
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
):
|
|
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, {
|
|
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
|
-
|
|
153
|
-
|
|
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;
|