@bitkyc08/opencodex 2.11.0 → 2.11.1
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/gui/dist/assets/{index-Bk-PN-70.css → index-BA1dgo4q.css} +1 -1
- package/gui/dist/assets/index-DDZpgzKk.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +6 -4
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/cursor/native-exec-common.ts +6 -2
- package/src/adapters/google-antigravity-replay.ts +3 -2
- package/src/adapters/google-antigravity-wire.ts +38 -6
- package/src/adapters/google.ts +57 -10
- package/src/adapters/openai-chat.ts +19 -2
- package/src/claude/model-info.ts +1 -1
- package/src/cli/account-auth.ts +3 -1
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +15 -1
- package/src/cli/codex-shim-readiness.ts +69 -0
- package/src/cli/combo.ts +5 -0
- package/src/cli/config-command.ts +33 -4
- package/src/cli/doctor.ts +21 -0
- package/src/cli/export-command.ts +6 -6
- package/src/cli/help.ts +6 -6
- package/src/cli/index.ts +22 -5
- package/src/cli/models-runtime.ts +13 -1
- package/src/cli/provider.ts +7 -0
- package/src/clients/config-export.ts +67 -5
- package/src/codex/account-lifecycle.ts +99 -10
- package/src/codex/auth-api.ts +222 -34
- package/src/codex/catalog/account-models.ts +9 -4
- package/src/codex/catalog/aggregation.ts +41 -8
- package/src/codex/catalog/bundled.ts +54 -22
- package/src/codex/catalog/effort.ts +47 -20
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +70 -18
- package/src/codex/catalog/native-models.ts +7 -0
- package/src/codex/catalog/parsing.ts +29 -11
- package/src/codex/catalog/provider-fetch.ts +335 -50
- package/src/codex/catalog/sync.ts +588 -126
- package/src/codex/catalog-refresh-status.ts +87 -0
- package/src/codex/catalog-write-serialization.ts +2 -1
- package/src/codex/catalog.ts +4 -3
- package/src/codex/convergence-types.ts +1 -1
- package/src/codex/convergence.ts +190 -52
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/features.ts +79 -1
- package/src/codex/history-job.ts +81 -1
- package/src/codex/history-lock.ts +2 -1
- package/src/codex/history-provider.ts +4 -3
- package/src/codex/inject.ts +56 -12
- package/src/codex/model-cache.ts +50 -10
- package/src/codex/transition-state.ts +10 -2
- package/src/codex/user-identity.ts +110 -2
- package/src/combos/index.ts +3 -0
- package/src/combos/types.ts +75 -9
- package/src/config.ts +26 -12
- package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +12 -10
- package/src/integrations/registry.ts +7 -0
- package/src/integrations/writer.ts +1 -1
- package/src/lib/pinned-http.ts +40 -9
- package/src/lib/process-control.ts +4 -1
- package/src/lib/provider-outbound.ts +42 -9
- package/src/oauth/cursor.ts +25 -5
- package/src/oauth/index.ts +59 -45
- package/src/providers/antigravity-models.ts +115 -3
- package/src/providers/context-cap.ts +13 -5
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +14 -15
- package/src/providers/provider-id-rewrite.ts +29 -0
- package/src/providers/quota.ts +93 -19
- package/src/providers/registry.ts +16 -5
- package/src/providers/slug-codec.ts +13 -6
- package/src/reasoning-effort.ts +22 -0
- package/src/router.ts +0 -20
- package/src/routing/history/indexer.ts +54 -39
- package/src/routing/quota.ts +77 -56
- package/src/server/index.ts +32 -7
- package/src/server/management/combo-routes.ts +43 -19
- package/src/server/management/config-routes.ts +115 -20
- package/src/server/management/model-routes.ts +10 -7
- package/src/server/management/model-rows.ts +6 -2
- package/src/server/management/oauth-account-routes.ts +12 -0
- package/src/server/management/provider-routes.ts +114 -40
- package/src/server/management/routing-profile-routes.ts +0 -22
- package/src/server/management-api.ts +7 -25
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/policy-fallback.ts +152 -0
- package/src/server/responses.ts +3 -2
- package/src/types.ts +15 -2
- package/src/usage/cost.ts +0 -0
- package/src/vision/describe.ts +3 -1
- package/src/vision/index.ts +17 -6
- package/src/vision/reasoning.ts +55 -0
- package/src/web-search/parse.ts +15 -3
- package/gui/dist/assets/index-BynIEIV-.js +0 -70
package/src/codex/features.ts
CHANGED
|
@@ -83,7 +83,23 @@ function readConfigText(configPath?: string): string | null {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Body lines of a TOML table `[header]` up to (not including) the next table header.
|
|
88
|
+
*
|
|
89
|
+
* The implementation body is deliberately unchanged by #1295 — only this comment
|
|
90
|
+
* is new. The scanner is line-based and string-unaware, so it ends the table at
|
|
91
|
+
* the first line matching `/^\s*\[/` even inside a multi-line value. Twenty call
|
|
92
|
+
* sites in this file consume its output, most of them by matching a regex
|
|
93
|
+
* against the returned text, so widening that text changes what they match. An
|
|
94
|
+
* earlier attempt at #1295 made this scanner string-aware and thereby gave
|
|
95
|
+
* `getAgentsEnabled`, `getAgentsMaxDepth`, and `getMaxConcurrentThreads` three
|
|
96
|
+
* new wrong answers.
|
|
97
|
+
*
|
|
98
|
+
* The readers that matter for #1295 use a real TOML parse instead (see
|
|
99
|
+
* `parsedTomlTable`). This stays as the fallback for documents that do not
|
|
100
|
+
* parse, and as the reader for the remaining call sites until they are migrated
|
|
101
|
+
* the same way.
|
|
102
|
+
*/
|
|
87
103
|
function tomlTableBody(content: string, header: string): string | null {
|
|
88
104
|
const lines = content.split("\n");
|
|
89
105
|
const escaped = header.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -109,8 +125,34 @@ function tomlBoolInBody(body: string, key: string): boolean | null {
|
|
|
109
125
|
*/
|
|
110
126
|
export function isMultiAgentV2Enabled(configPath?: string): boolean {
|
|
111
127
|
const content = readConfigText(configPath);
|
|
128
|
+
return multiAgentV2EnabledFromConfigText(content);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Parse `multi_agent_v2` from caller-owned config.toml text without consulting disk. */
|
|
132
|
+
export function multiAgentV2EnabledFromConfigText(content: string | null): boolean {
|
|
112
133
|
if (content === null) return false;
|
|
113
134
|
|
|
135
|
+
// Prefer a real parse. The hand-written table scanner below cannot distinguish
|
|
136
|
+
// an assignment from prose that looks like one — a `"""` value containing the
|
|
137
|
+
// line `enabled = true` reads as the key itself — and TOML has enough value
|
|
138
|
+
// shapes (multi-line arrays opening on the next line, escapes, comments) that
|
|
139
|
+
// each near-miss costs another special case (#1295).
|
|
140
|
+
//
|
|
141
|
+
// The scanner remains only for a document `Bun.TOML.parse` rejects. That is a
|
|
142
|
+
// statement about Bun's parser, not about Codex's — the two are separate
|
|
143
|
+
// implementations and no compatibility evidence is claimed here, so a document
|
|
144
|
+
// Bun rejects may still be one Codex loads. The fallback is therefore
|
|
145
|
+
// best-effort and inherits the ambiguity above. It exists because reporting a
|
|
146
|
+
// feature as disabled on account of an unreadable file presents a failure as
|
|
147
|
+
// a state.
|
|
148
|
+
const parsed = parsedTomlTable(content, "features");
|
|
149
|
+
if (parsed !== null) {
|
|
150
|
+
const table = plainTomlRecord(parsed.multi_agent_v2);
|
|
151
|
+
if (table !== null) return table.enabled === true;
|
|
152
|
+
if (typeof parsed.multi_agent_v2 === "boolean") return parsed.multi_agent_v2;
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
114
156
|
const table = tomlTableBody(content, "features.multi_agent_v2");
|
|
115
157
|
if (table !== null) {
|
|
116
158
|
const enabled = tomlBoolInBody(table, "enabled");
|
|
@@ -134,6 +176,30 @@ export function isMultiAgentV2Enabled(configPath?: string): boolean {
|
|
|
134
176
|
return false;
|
|
135
177
|
}
|
|
136
178
|
|
|
179
|
+
function plainTomlRecord(value: unknown): Record<string, unknown> | null {
|
|
180
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
181
|
+
? value as Record<string, unknown>
|
|
182
|
+
: null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A top-level table from a full TOML parse, or null when the document does not
|
|
187
|
+
* parse. A parsed document with no such table yields `{}` rather than null: that
|
|
188
|
+
* is a real answer ("no keys"), while null means "could not read, fall back".
|
|
189
|
+
*/
|
|
190
|
+
function parsedTomlTable(content: string, name: string): Record<string, unknown> | null {
|
|
191
|
+
const toml = (globalThis as { Bun?: { TOML?: { parse(input: string): unknown } } }).Bun?.TOML;
|
|
192
|
+
if (!toml) return null;
|
|
193
|
+
try {
|
|
194
|
+
const root = plainTomlRecord(toml.parse(content));
|
|
195
|
+
if (root === null) return null;
|
|
196
|
+
return plainTomlRecord(root[name]) ?? {};
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
137
203
|
/**
|
|
138
204
|
* TRUE when the codex `default_mode_request_user_input` feature is enabled in
|
|
139
205
|
* config.toml — lets a Default-mode session pause and ask the user questions
|
|
@@ -145,6 +211,11 @@ export function isMultiAgentV2Enabled(configPath?: string): boolean {
|
|
|
145
211
|
export function isDefaultModeRequestUserInputEnabled(configPath?: string): boolean {
|
|
146
212
|
const content = readConfigText(configPath);
|
|
147
213
|
if (content === null) return false;
|
|
214
|
+
// Same reason as the v2 reader: a `"""` value whose prose contains
|
|
215
|
+
// `default_mode_request_user_input = true` is not an assignment, and a raw
|
|
216
|
+
// regex over the table body cannot tell the difference (#1295).
|
|
217
|
+
const parsed = parsedTomlTable(content, "features");
|
|
218
|
+
if (parsed !== null) return parsed[DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY] === true;
|
|
148
219
|
const features = tomlTableBody(content, "features");
|
|
149
220
|
if (features === null) return false;
|
|
150
221
|
return tomlBoolInBody(features, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY) === true;
|
|
@@ -159,6 +230,8 @@ export function isDefaultModeRequestUserInputEnabled(configPath?: string): boole
|
|
|
159
230
|
export function hasAgentsMaxThreads(configPath?: string): boolean {
|
|
160
231
|
const content = readConfigText(configPath);
|
|
161
232
|
if (content === null) return false;
|
|
233
|
+
const parsed = parsedTomlTable(content, "agents");
|
|
234
|
+
if (parsed !== null) return Object.hasOwn(parsed, "max_threads");
|
|
162
235
|
const agents = tomlTableBody(content, "agents");
|
|
163
236
|
if (agents === null) return false;
|
|
164
237
|
return /^\s*max_threads\s*=/m.test(agents);
|
|
@@ -168,6 +241,11 @@ export function hasAgentsMaxThreads(configPath?: string): boolean {
|
|
|
168
241
|
export function getAgentsMaxThreads(configPath?: string): number | null {
|
|
169
242
|
const content = readConfigText(configPath);
|
|
170
243
|
if (content === null) return null;
|
|
244
|
+
const parsed = parsedTomlTable(content, "agents");
|
|
245
|
+
if (parsed !== null) {
|
|
246
|
+
const value = parsed.max_threads;
|
|
247
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : null;
|
|
248
|
+
}
|
|
171
249
|
const agents = tomlTableBody(content, "agents");
|
|
172
250
|
if (agents === null) return null;
|
|
173
251
|
const m = agents.match(/^\s*max_threads\s*=\s*(\d+)\s*(?:#.*)?$/m);
|
package/src/codex/history-job.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* Design record: devlog/_fin/260804_codex_write_substrate/020_history_isolation.md.
|
|
18
18
|
*/
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { homedir } from "node:os";
|
|
20
21
|
import { join } from "node:path";
|
|
21
22
|
|
|
22
23
|
import type {
|
|
@@ -135,13 +136,92 @@ export function deriveCodexHistoryOperation(intent: {
|
|
|
135
136
|
return intent.legacyMode ? "apply-opencodex" : "migrate-openai";
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The honest failure clause for one history job outcome.
|
|
141
|
+
*
|
|
142
|
+
* The caller adds its own framing ("sync SKIPPED", "could NOT be restored").
|
|
143
|
+
* The point of the surface argument is that a genuine lock keeps today's
|
|
144
|
+
* actionable wording, while every other reason stops blaming the Codex app:
|
|
145
|
+
* an unsafe-path refusal, an unavailable coordinator database, a permission
|
|
146
|
+
* denial, or a dead worker is a different problem with a different remedy.
|
|
147
|
+
*/
|
|
148
|
+
export function describeHistoryJobFailure(
|
|
149
|
+
outcome: CodexHistoryJobOutcome,
|
|
150
|
+
surface: "apply" | "restore" | "recover-legacy",
|
|
151
|
+
legacyMode = false,
|
|
152
|
+
): string {
|
|
153
|
+
// Callers only invoke this after observing a failure flag, but that flag is
|
|
154
|
+
// derived from "not converged", which also covers "skipped". Naming those
|
|
155
|
+
// two kinds keeps a widened or miscast call site from printing `undefined`.
|
|
156
|
+
if (outcome.kind === "skipped") {
|
|
157
|
+
return "the history operation was skipped; no failure was recorded.";
|
|
158
|
+
}
|
|
159
|
+
if (outcome.kind === "converged") {
|
|
160
|
+
return "the history job reported no failure; run 'ocx doctor' if this is unexpected.";
|
|
161
|
+
}
|
|
162
|
+
// A busy database reaches here two ways: the lock itself was contended
|
|
163
|
+
// (blocked/busy), or the lock was acquired and the worker then found SQLite
|
|
164
|
+
// busy (failed with historyFailureReason "busy"). Both are the same user
|
|
165
|
+
// situation and deserve the same surface-specific guidance.
|
|
166
|
+
const busyText = surface === "apply"
|
|
167
|
+
? legacyMode
|
|
168
|
+
? "the history DB is locked (Codex app/IDE open?). Close it and rerun 'ocx start'."
|
|
169
|
+
: "the history DB is locked (Codex app/IDE open?). It is retried automatically (while the proxy runs and on every 'ocx start'); to force it now, close the Codex app and run 'ocx sync'."
|
|
170
|
+
: surface === "recover-legacy"
|
|
171
|
+
? "the Codex history DB is locked (Codex app/IDE open?). Close it and rerun this command."
|
|
172
|
+
: "the Codex app appears to be holding the history database. Close Codex and run `ocx restore` again.";
|
|
173
|
+
if (outcome.kind === "blocked") {
|
|
174
|
+
if (outcome.reason === "busy") return busyText;
|
|
175
|
+
switch (outcome.reason) {
|
|
176
|
+
case "unsafe-path":
|
|
177
|
+
return "opencodex refused its history lock path (unsafe coordinator namespace); this is not a Codex app lock. Run 'ocx doctor' and check the opencodex runtime directory.";
|
|
178
|
+
case "database":
|
|
179
|
+
return "the history coordinator database is unavailable; this is not a Codex app lock. Run 'ocx doctor'.";
|
|
180
|
+
case "desired_disabled":
|
|
181
|
+
return "Codex integration is disabled, so the history operation was skipped.";
|
|
182
|
+
case "desired_enabled":
|
|
183
|
+
return "Codex integration is enabled, so the history operation was skipped.";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (outcome.historyFailureReason === "busy") return busyText;
|
|
187
|
+
if (outcome.historyFailureReason === "permission") {
|
|
188
|
+
return "permission was denied while writing Codex history; this is not a Codex app lock. Run 'ocx doctor'.";
|
|
189
|
+
}
|
|
190
|
+
switch (outcome.reason) {
|
|
191
|
+
case "worker-error":
|
|
192
|
+
return `the history worker failed (${outcome.message}). Run 'ocx doctor'.`;
|
|
193
|
+
case "worker-died":
|
|
194
|
+
return "the history worker exited unexpectedly; this is not a Codex app lock. Run 'ocx doctor'.";
|
|
195
|
+
case "timeout":
|
|
196
|
+
return "the history worker timed out; this is not a Codex app lock. Run 'ocx doctor'.";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Worker exceptions travel into user-facing CLI output, and a raw filesystem
|
|
202
|
+
* error carries absolute paths — on every platform that includes the account
|
|
203
|
+
* name (`/Users/x`, `/home/x`, `C:\Users\x`). Folding the home directory to
|
|
204
|
+
* `~` keeps the diagnostic value and drops the identifier.
|
|
205
|
+
*/
|
|
206
|
+
function redactWorkerMessage(message: string): string {
|
|
207
|
+
const home = homedir();
|
|
208
|
+
if (home.length <= 1) return message;
|
|
209
|
+
// Windows spellings vary in case and separator; an exact match would leave
|
|
210
|
+
// the account name in the message.
|
|
211
|
+
if (process.platform === "win32") {
|
|
212
|
+
const escaped = home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\\/g, "[\\\\/]");
|
|
213
|
+
return message.replace(new RegExp(escaped, "gi"), "~");
|
|
214
|
+
}
|
|
215
|
+
return message.split(home).join("~");
|
|
216
|
+
}
|
|
217
|
+
|
|
138
218
|
function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutcome {
|
|
139
219
|
if (result.type === "blocked") return { kind: "blocked", reason: result.reason };
|
|
140
220
|
if (result.type === "error") {
|
|
141
221
|
return {
|
|
142
222
|
kind: "failed",
|
|
143
223
|
reason: "worker-error",
|
|
144
|
-
message: result.message,
|
|
224
|
+
message: redactWorkerMessage(result.message),
|
|
145
225
|
...(result.reason ? { historyFailureReason: result.reason } : {}),
|
|
146
226
|
};
|
|
147
227
|
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
CodexUserIdentityRefusal,
|
|
38
38
|
resolveCodexHistorySerializationDatabasePath,
|
|
39
39
|
resolveEffectiveUserIdentity,
|
|
40
|
+
samePathIdentity,
|
|
40
41
|
} from "./user-identity";
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -183,7 +184,7 @@ export function withHistoryWriteSerialization<T>(
|
|
|
183
184
|
}
|
|
184
185
|
const opened = lstatSync(databasePath);
|
|
185
186
|
if (opened.isSymbolicLink() || !opened.isFile()
|
|
186
|
-
|| realpathSync.native(databasePath)
|
|
187
|
+
|| !samePathIdentity(realpathSync.native(databasePath), databasePath)) {
|
|
187
188
|
return { kind: "unavailable", reason: "unsafe-path" };
|
|
188
189
|
}
|
|
189
190
|
|
|
@@ -722,16 +722,17 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
|
|
|
722
722
|
}
|
|
723
723
|
}
|
|
724
724
|
|
|
725
|
-
export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH):
|
|
725
|
+
export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): CodexHistorySyncResult {
|
|
726
726
|
if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
|
|
727
|
-
|
|
727
|
+
const retried = withHistoryRetryResult(() => {
|
|
728
728
|
const db = openStateDb(stateDbPath);
|
|
729
729
|
try {
|
|
730
730
|
return ejectRemainingOpencodexHistory(db);
|
|
731
731
|
} finally {
|
|
732
732
|
db.close();
|
|
733
733
|
}
|
|
734
|
-
})
|
|
734
|
+
});
|
|
735
|
+
return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
|
|
735
736
|
}
|
|
736
737
|
|
|
737
738
|
/**
|
package/src/codex/inject.ts
CHANGED
|
@@ -38,9 +38,11 @@ import { withCatalogWriteSerialization } from "./catalog-write-serialization";
|
|
|
38
38
|
import { restoreCodexCatalogWithPermit } from "./catalog/sync";
|
|
39
39
|
import { syncCodexHistoryProvider, type CodexHistoryFailureReason } from "./history-provider";
|
|
40
40
|
import {
|
|
41
|
+
describeHistoryJobFailure,
|
|
41
42
|
deriveCodexHistoryOperation,
|
|
42
43
|
resolveCodexHistoryJobTarget,
|
|
43
44
|
runCodexHistoryJob,
|
|
45
|
+
type CodexHistoryJobOutcome,
|
|
44
46
|
} from "./history-job";
|
|
45
47
|
import {
|
|
46
48
|
OCX_SECTION_MARKER,
|
|
@@ -1034,11 +1036,7 @@ export async function injectCodexConfig(
|
|
|
1034
1036
|
config?.syncResumeHistory === false
|
|
1035
1037
|
? ` Codex resume history: left unchanged (syncResumeHistory=false).\n`
|
|
1036
1038
|
: history.failed
|
|
1037
|
-
? legacyMode
|
|
1038
|
-
? ` ⚠️ Codex resume history sync SKIPPED: the history DB is locked (Codex app/IDE open?). Close it and rerun 'ocx start'.\n`
|
|
1039
|
-
: // Honest in every caller context: the daemon retries in the background while it runs,
|
|
1040
|
-
// and this inject path re-runs the migration on every future start/sync anyway.
|
|
1041
|
-
` ⚠️ Codex resume history migration deferred: the history DB is locked (Codex app/IDE open?). It is retried automatically (while the proxy runs and on every 'ocx start'); to force it now, close the Codex app and run 'ocx sync'.\n`
|
|
1039
|
+
? formatApplyHistoryFailure(historyOutcome, legacyMode)
|
|
1042
1040
|
: legacyMode
|
|
1043
1041
|
? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n`
|
|
1044
1042
|
: migratedRows > 0
|
|
@@ -1250,7 +1248,7 @@ export interface CodexNativeRestoreResult {
|
|
|
1250
1248
|
};
|
|
1251
1249
|
}
|
|
1252
1250
|
|
|
1253
|
-
function failedHistoryRestore(reason?: CodexHistoryFailureReason): CodexRestoreHistoryResult {
|
|
1251
|
+
function failedHistoryRestore(reason?: CodexHistoryFailureReason, detail?: string): CodexRestoreHistoryResult {
|
|
1254
1252
|
return {
|
|
1255
1253
|
state: "failed",
|
|
1256
1254
|
changed: false,
|
|
@@ -1260,10 +1258,36 @@ function failedHistoryRestore(reason?: CodexHistoryFailureReason): CodexRestoreH
|
|
|
1260
1258
|
ejectedRows: 0,
|
|
1261
1259
|
message: reason === "permission"
|
|
1262
1260
|
? "Codex resume history could NOT be restored because permission was denied."
|
|
1263
|
-
:
|
|
1261
|
+
: reason === "busy"
|
|
1262
|
+
? "Codex resume history could NOT be restored — the Codex app appears to be holding the history database."
|
|
1263
|
+
: detail
|
|
1264
|
+
? `Codex resume history could NOT be restored: ${detail}`
|
|
1265
|
+
: "Codex resume history could NOT be restored; the reason was not recorded. Run 'ocx doctor'.",
|
|
1264
1266
|
};
|
|
1265
1267
|
}
|
|
1266
1268
|
|
|
1269
|
+
/**
|
|
1270
|
+
* Restore failure wording for a Worker outcome.
|
|
1271
|
+
*
|
|
1272
|
+
* Only a genuine busy result blames the Codex app. An unsafe-path refusal, an
|
|
1273
|
+
* unavailable coordinator database, a permission denial, or a dead/timed-out
|
|
1274
|
+
* worker is a different problem; the old collapse made every one of those read
|
|
1275
|
+
* as "the Codex app is holding the database" (issue #1191). `busy` and
|
|
1276
|
+
* `permission` keep the restore-specific sentence built by
|
|
1277
|
+
* `failedHistoryRestore`; every other reason reuses the single formatter so
|
|
1278
|
+
* the two modules cannot drift apart.
|
|
1279
|
+
*/
|
|
1280
|
+
export function failedHistoryRestoreFromOutcome(
|
|
1281
|
+
outcome: Extract<CodexHistoryJobOutcome, { kind: "blocked" | "failed" }>,
|
|
1282
|
+
): CodexRestoreHistoryResult {
|
|
1283
|
+
if (outcome.kind === "blocked" && outcome.reason === "busy") return failedHistoryRestore("busy");
|
|
1284
|
+
if (outcome.kind === "failed" && outcome.historyFailureReason === "busy") return failedHistoryRestore("busy");
|
|
1285
|
+
if (outcome.kind === "failed" && outcome.historyFailureReason === "permission") {
|
|
1286
|
+
return failedHistoryRestore("permission");
|
|
1287
|
+
}
|
|
1288
|
+
return failedHistoryRestore(undefined, describeHistoryJobFailure(outcome, "restore"));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1267
1291
|
function externalProviderRestoreResult(activeProvider: string): CodexNativeRestoreResult {
|
|
1268
1292
|
const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`;
|
|
1269
1293
|
return {
|
|
@@ -1499,11 +1523,9 @@ export async function restoreNativeCodexAsync(
|
|
|
1499
1523
|
? "Codex integration was disabled; history restoration was skipped."
|
|
1500
1524
|
: "Codex integration was enabled; history restoration was skipped.",
|
|
1501
1525
|
}
|
|
1502
|
-
: outcome.kind === "blocked"
|
|
1503
|
-
?
|
|
1504
|
-
:
|
|
1505
|
-
? failedHistoryRestore(outcome.historyFailureReason)
|
|
1506
|
-
: failedHistoryRestore();
|
|
1526
|
+
: outcome.kind === "blocked" || outcome.kind === "failed"
|
|
1527
|
+
? failedHistoryRestoreFromOutcome(outcome)
|
|
1528
|
+
: failedHistoryRestore();
|
|
1507
1529
|
const base = catalog.removed > 0
|
|
1508
1530
|
? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).`
|
|
1509
1531
|
: config.message;
|
|
@@ -1572,3 +1594,25 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD
|
|
|
1572
1594
|
export function getCodexConfigPath(): string {
|
|
1573
1595
|
return CODEX_CONFIG_PATH;
|
|
1574
1596
|
}
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* Frame one failed apply history job honestly.
|
|
1600
|
+
*
|
|
1601
|
+
* A genuine lock keeps the established deferred/SKIPPED wording; any other
|
|
1602
|
+
* reason names itself instead of blaming the Codex app/IDE.
|
|
1603
|
+
*/
|
|
1604
|
+
export function formatApplyHistoryFailure(outcome: CodexHistoryJobOutcome, legacyMode: boolean): string {
|
|
1605
|
+
// A busy database is a deferral no matter which half observed it: the lock
|
|
1606
|
+
// contended (blocked/busy), or the worker acquired the lock and then found
|
|
1607
|
+
// SQLite busy (failed with a busy history reason). Only those keep the
|
|
1608
|
+
// deferred headline; every other failure is a real "NOT changed".
|
|
1609
|
+
const busy =
|
|
1610
|
+
(outcome.kind === "blocked" && outcome.reason === "busy") ||
|
|
1611
|
+
(outcome.kind === "failed" && outcome.historyFailureReason === "busy");
|
|
1612
|
+
const headline = legacyMode
|
|
1613
|
+
? "Codex resume history sync SKIPPED"
|
|
1614
|
+
: busy
|
|
1615
|
+
? "Codex resume history migration deferred"
|
|
1616
|
+
: "Codex resume history NOT changed";
|
|
1617
|
+
return ` ⚠️ ${headline}: ${describeHistoryJobFailure(outcome, "apply", legacyMode)}\n`;
|
|
1618
|
+
}
|
package/src/codex/model-cache.ts
CHANGED
|
@@ -42,7 +42,12 @@ export type ProviderModelDiscoveryFailure = ProviderModelDiscoveryStatus extends
|
|
|
42
42
|
: never
|
|
43
43
|
: never;
|
|
44
44
|
|
|
45
|
+
/** Whether clearing cache rows also revokes in-flight discovery authority. */
|
|
46
|
+
export type ModelCacheClearReason = "authority" | "eviction";
|
|
47
|
+
|
|
45
48
|
const cache = new Map<string, CacheEntry>();
|
|
49
|
+
let globalCacheGeneration = 0;
|
|
50
|
+
const providerCacheGenerations = new Map<string, number>();
|
|
46
51
|
let cacheBytes = 0;
|
|
47
52
|
let oldestCachedProvider: string | undefined;
|
|
48
53
|
let oldestCachedAt: number | null = null;
|
|
@@ -155,7 +160,29 @@ export function getStaleCached(provider: string): CatalogModel[] | null {
|
|
|
155
160
|
return cache.get(provider)?.models ?? null;
|
|
156
161
|
}
|
|
157
162
|
|
|
158
|
-
|
|
163
|
+
/** Capture the cache generation before an asynchronous provider discovery starts. */
|
|
164
|
+
export function captureModelCacheGeneration(provider: string): string {
|
|
165
|
+
if (!providerCacheGenerations.has(provider)) providerCacheGenerations.set(provider, 0);
|
|
166
|
+
return `${globalCacheGeneration}:${providerCacheGenerations.get(provider)!}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Whether a discovery started under {@link captureModelCacheGeneration} may still write. */
|
|
170
|
+
export function isModelCacheGenerationCurrent(provider: string, generation: string): boolean {
|
|
171
|
+
return generation === captureModelCacheGeneration(provider);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Store a live result unless the cache was cleared while that asynchronous discovery was running.
|
|
176
|
+
* The optional generation keeps existing direct cache writers unchanged while discovery callers can
|
|
177
|
+
* prevent a previous OAuth account from repopulating the current account's cache.
|
|
178
|
+
*/
|
|
179
|
+
export function setCached(
|
|
180
|
+
provider: string,
|
|
181
|
+
models: CatalogModel[],
|
|
182
|
+
now = Date.now(),
|
|
183
|
+
generation?: string,
|
|
184
|
+
): boolean {
|
|
185
|
+
if (generation !== undefined && !isModelCacheGenerationCurrent(provider, generation)) return false;
|
|
159
186
|
deleteCachedProvider(provider);
|
|
160
187
|
const sizeBytes = modelCacheEncoder.encode(provider).byteLength
|
|
161
188
|
+ modelCacheEncoder.encode(JSON.stringify(models)).byteLength;
|
|
@@ -166,16 +193,25 @@ export function setCached(provider: string, models: CatalogModel[], now = Date.n
|
|
|
166
193
|
oldestCachedAt = now;
|
|
167
194
|
}
|
|
168
195
|
enforceAppOwnedMemoryBudget();
|
|
196
|
+
return true;
|
|
169
197
|
}
|
|
170
198
|
|
|
171
199
|
/** Drop one provider's cache (or all) so the next resolve forces a live re-fetch. */
|
|
172
|
-
export function clearModelCache(
|
|
200
|
+
export function clearModelCache(
|
|
201
|
+
provider?: string,
|
|
202
|
+
reason: ModelCacheClearReason = "authority",
|
|
203
|
+
): void {
|
|
204
|
+
const revokesInFlightDiscovery = reason === "authority";
|
|
173
205
|
if (provider) {
|
|
206
|
+
if (revokesInFlightDiscovery) {
|
|
207
|
+
providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1);
|
|
208
|
+
}
|
|
174
209
|
deleteCachedProvider(provider);
|
|
175
210
|
failureAt.delete(provider);
|
|
176
211
|
discoveryStatus.delete(provider);
|
|
177
212
|
liveModelCounts.delete(provider);
|
|
178
213
|
} else {
|
|
214
|
+
if (revokesInFlightDiscovery) globalCacheGeneration += 1;
|
|
179
215
|
cache.clear();
|
|
180
216
|
cacheBytes = 0;
|
|
181
217
|
oldestCachedProvider = undefined;
|
|
@@ -192,16 +228,20 @@ export function reconcileModelCacheProviders(
|
|
|
192
228
|
): number {
|
|
193
229
|
if (generation <= lastReconciledGeneration) return 0;
|
|
194
230
|
const removedProviders = new Set<string>();
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
for (const provider of
|
|
231
|
+
const trackedProviders = new Set([
|
|
232
|
+
...providerCacheGenerations.keys(),
|
|
233
|
+
...failureAt.keys(),
|
|
234
|
+
...discoveryStatus.keys(),
|
|
235
|
+
...liveModelCounts.keys(),
|
|
236
|
+
...cache.keys(),
|
|
237
|
+
]);
|
|
238
|
+
for (const provider of trackedProviders) {
|
|
203
239
|
if (validProviders.has(provider)) continue;
|
|
240
|
+
providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1);
|
|
204
241
|
deleteCachedProvider(provider);
|
|
242
|
+
failureAt.delete(provider);
|
|
243
|
+
discoveryStatus.delete(provider);
|
|
244
|
+
liveModelCounts.delete(provider);
|
|
205
245
|
removedProviders.add(provider);
|
|
206
246
|
}
|
|
207
247
|
lastReconciledGeneration = generation;
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
CodexUserIdentityRefusal,
|
|
35
35
|
resolveCodexCoordinatorDatabasePath,
|
|
36
36
|
resolveEffectiveUserIdentity,
|
|
37
|
+
samePathIdentity,
|
|
37
38
|
} from "./user-identity";
|
|
38
39
|
|
|
39
40
|
const COORDINATOR_SCHEMA_VERSION = 1;
|
|
@@ -352,6 +353,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code
|
|
|
352
353
|
let lastResult: TransitionStateUpdate | undefined;
|
|
353
354
|
let initialIdentity: string | undefined;
|
|
354
355
|
let databaseWasAbsent = false;
|
|
356
|
+
let databaseWasEmpty = false;
|
|
355
357
|
|
|
356
358
|
try {
|
|
357
359
|
try {
|
|
@@ -359,6 +361,12 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code
|
|
|
359
361
|
if (before.isSymbolicLink() || !before.isFile()) {
|
|
360
362
|
throw new CodexUserIdentityRefusal("The coordinator database path is not a real file.");
|
|
361
363
|
}
|
|
364
|
+
// sqlite3_open_v2(..., SQLITE_OPEN_CREATE) makes the pathname visible
|
|
365
|
+
// before the first schema write. A racing process can therefore observe a
|
|
366
|
+
// real but zero-byte file that carries no coordinator authority yet. Treat
|
|
367
|
+
// that exact state like ENOENT; any non-empty unversioned database remains
|
|
368
|
+
// legacy-ambiguous below.
|
|
369
|
+
databaseWasEmpty = before.size === 0;
|
|
362
370
|
if (process.platform !== "win32") {
|
|
363
371
|
const uid = process.getuid?.();
|
|
364
372
|
// Ownership is decided here; MODE is not.
|
|
@@ -415,7 +423,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code
|
|
|
415
423
|
initialIdentity = `${opened.dev}:${opened.ino}`;
|
|
416
424
|
database.exec("PRAGMA busy_timeout = 0; PRAGMA locking_mode = NORMAL; BEGIN IMMEDIATE");
|
|
417
425
|
transactionOpen = true;
|
|
418
|
-
initialize(database, databaseWasAbsent);
|
|
426
|
+
initialize(database, databaseWasAbsent || databaseWasEmpty);
|
|
419
427
|
} catch (cause) {
|
|
420
428
|
if (transactionOpen) {
|
|
421
429
|
try { database?.exec("ROLLBACK"); } catch { /* close releases the transaction */ }
|
|
@@ -433,7 +441,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code
|
|
|
433
441
|
const entry = lstatSync(finalDatabasePath);
|
|
434
442
|
if (entry.isSymbolicLink() || !entry.isFile()
|
|
435
443
|
|| `${entry.dev}:${entry.ino}` !== initialIdentity
|
|
436
|
-
|| realpathSync.native(finalDatabasePath)
|
|
444
|
+
|| !samePathIdentity(realpathSync.native(finalDatabasePath), finalDatabasePath)) {
|
|
437
445
|
throw new CodexUserIdentityRefusal("The coordinator database path was substituted.");
|
|
438
446
|
}
|
|
439
447
|
};
|
|
@@ -123,7 +123,7 @@ function ensurePrivatePosixDirectory(path: string, uid: number): void {
|
|
|
123
123
|
assertPrivatePosixDirectory(path, uid);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
function
|
|
126
|
+
function resolveTrustedPosixTmp(): string {
|
|
127
127
|
let realTmp: string;
|
|
128
128
|
try {
|
|
129
129
|
realTmp = realpathSync.native(POSIX_TMP_PATH);
|
|
@@ -138,12 +138,82 @@ function resolvePosixRuntimeRoot(uid: number): string {
|
|
|
138
138
|
if (cause instanceof CodexUserIdentityRefusal) throw cause;
|
|
139
139
|
refuse("The system temporary directory cannot be trusted.", cause);
|
|
140
140
|
}
|
|
141
|
+
return realTmp;
|
|
142
|
+
}
|
|
141
143
|
|
|
144
|
+
function resolvePosixRuntimeRoot(uid: number): string {
|
|
145
|
+
const realTmp = resolveTrustedPosixTmp();
|
|
142
146
|
const root = join(realTmp, `opencodex-runtime-v1-${uid}`);
|
|
143
147
|
ensurePrivatePosixDirectory(root, uid);
|
|
144
148
|
return root;
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
export type CoordinatorNamespaceProbe =
|
|
152
|
+
| { readonly status: "ok"; readonly root: string }
|
|
153
|
+
| { readonly status: "missing" };
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read-only namespace probe for diagnostics (`ocx doctor`).
|
|
157
|
+
*
|
|
158
|
+
* Unlike the runtime resolvers, this never creates the root or the lock
|
|
159
|
+
* directories: a doctor run must observe the namespace, not initialize it.
|
|
160
|
+
* A missing namespace is reported as `missing` instead of refused, so a fresh
|
|
161
|
+
* machine does not read as a broken one; an existing but unsafe namespace is
|
|
162
|
+
* refused exactly like the creating path would refuse it.
|
|
163
|
+
*/
|
|
164
|
+
export function probeCodexCoordinatorNamespace(identity: UserIdentity): CoordinatorNamespaceProbe {
|
|
165
|
+
if (identity.platform === "posix") {
|
|
166
|
+
const root = join(resolveTrustedPosixTmp(), `opencodex-runtime-v1-${identity.uid}`);
|
|
167
|
+
let entry;
|
|
168
|
+
try {
|
|
169
|
+
entry = lstatSync(root);
|
|
170
|
+
} catch (cause) {
|
|
171
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
172
|
+
? String((cause as { code?: unknown }).code)
|
|
173
|
+
: "";
|
|
174
|
+
if (code === "ENOENT") return { status: "missing" };
|
|
175
|
+
refuse("The Codex coordinator namespace cannot be inspected.", cause);
|
|
176
|
+
}
|
|
177
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
178
|
+
refuse("The Codex coordinator namespace is not a real directory.");
|
|
179
|
+
}
|
|
180
|
+
if (entry.uid !== identity.uid || (entry.mode & 0o777) !== POSIX_PRIVATE_MODE) {
|
|
181
|
+
refuse("The Codex coordinator namespace has unsafe ownership or permissions.");
|
|
182
|
+
}
|
|
183
|
+
return { status: "ok", root };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!SID_PATTERN.test(identity.sid)) refuse("The coordinator identity contains an invalid SID.");
|
|
187
|
+
const localAppData = powershellValue(
|
|
188
|
+
"[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)",
|
|
189
|
+
);
|
|
190
|
+
if (!isAbsolute(localAppData)) refuse("Windows LocalAppData resolution returned a relative path.");
|
|
191
|
+
const root = resolve(localAppData, "OpenCodex", "Runtime", "v1", identity.sid.toUpperCase());
|
|
192
|
+
let entry;
|
|
193
|
+
try {
|
|
194
|
+
entry = lstatSync(root);
|
|
195
|
+
} catch (cause) {
|
|
196
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
197
|
+
? String((cause as { code?: unknown }).code)
|
|
198
|
+
: "";
|
|
199
|
+
if (code === "ENOENT") return { status: "missing" };
|
|
200
|
+
refuse("The Windows coordinator namespace cannot be inspected.", cause);
|
|
201
|
+
}
|
|
202
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
203
|
+
refuse("The Windows coordinator namespace is not a real directory.");
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const real = realpathSync.native(root);
|
|
207
|
+
if (!samePathIdentity(real, root, "win32")) {
|
|
208
|
+
refuse("The Windows coordinator namespace is redirected by a junction or reparse point.");
|
|
209
|
+
}
|
|
210
|
+
return { status: "ok", root: real };
|
|
211
|
+
} catch (cause) {
|
|
212
|
+
if (cause instanceof CodexUserIdentityRefusal) throw cause;
|
|
213
|
+
refuse("The Windows coordinator namespace cannot be resolved.", cause);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
147
217
|
function resolveWindowsRuntimeRoot(identity: Extract<UserIdentity, { platform: "win32" }>): string {
|
|
148
218
|
if (!SID_PATTERN.test(identity.sid)) refuse("The coordinator identity contains an invalid SID.");
|
|
149
219
|
const localAppData = powershellValue(
|
|
@@ -160,7 +230,45 @@ function resolveWindowsRuntimeRoot(identity: Extract<UserIdentity, { platform: "
|
|
|
160
230
|
} catch (cause) {
|
|
161
231
|
refuse("The Windows coordinator namespace cannot be created.", cause);
|
|
162
232
|
}
|
|
163
|
-
|
|
233
|
+
// Canonicalize before anything is keyed on the path: a junctioned or
|
|
234
|
+
// differently-cased LocalAppData must land on ONE namespace, or two processes
|
|
235
|
+
// that share the real directory would build different lock paths and never
|
|
236
|
+
// contend. The lock modules also compare this path against realpath, so a
|
|
237
|
+
// non-canonical spelling here would read as "unsafe" on every acquisition.
|
|
238
|
+
//
|
|
239
|
+
// Canonicalizing must only ever fold spelling (case, separators). If the
|
|
240
|
+
// realpath lands somewhere else entirely, a component of the namespace is a
|
|
241
|
+
// junction/reparse redirect; accepting the target would convert the old
|
|
242
|
+
// refusal into silently opening the redirected location, so refuse instead.
|
|
243
|
+
try {
|
|
244
|
+
const real = realpathSync.native(root);
|
|
245
|
+
if (!samePathIdentity(real, root, "win32")) {
|
|
246
|
+
refuse("The Windows coordinator namespace is redirected by a junction or reparse point.");
|
|
247
|
+
}
|
|
248
|
+
return real;
|
|
249
|
+
} catch (cause) {
|
|
250
|
+
if (cause instanceof CodexUserIdentityRefusal) throw cause;
|
|
251
|
+
refuse("The Windows coordinator namespace cannot be resolved.", cause);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Windows path identity is case-insensitive; everywhere else it is exact.
|
|
257
|
+
*
|
|
258
|
+
* The lock modules compare a requested lock path against its own realpath, and
|
|
259
|
+
* byte equality refuses legitimate Windows spellings (drive-letter case, mixed
|
|
260
|
+
* component casing) as "unsafe". This is the same semantics
|
|
261
|
+
* `history-provider.ts` already applies to manifest paths; the platform
|
|
262
|
+
* argument exists so both branches are testable on any host.
|
|
263
|
+
*/
|
|
264
|
+
export function samePathIdentity(
|
|
265
|
+
a: string,
|
|
266
|
+
b: string,
|
|
267
|
+
platform: NodeJS.Platform = process.platform,
|
|
268
|
+
): boolean {
|
|
269
|
+
const left = resolve(a);
|
|
270
|
+
const right = resolve(b);
|
|
271
|
+
return platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
164
272
|
}
|
|
165
273
|
|
|
166
274
|
export const resolveCodexCoordinatorDatabasePath: ResolveCodexCoordinatorDatabasePath = (
|