@bitkyc08/opencodex 2.6.7 → 2.6.8

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.
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { Database } from "bun:sqlite";
5
5
  import { CODEX_HOME } from "./codex-paths";
@@ -14,6 +14,130 @@ function historyBackupPathFor(stateDbPath: string): string {
14
14
  const HISTORY_BACKUP_PATH = historyBackupPathFor(STATE_DB_PATH);
15
15
  const RESUMABLE_SOURCES = ["cli", "vscode"] as const;
16
16
 
17
+ /**
18
+ * Open the live `state_5.sqlite` the way the Codex app expects a *secondary* writer to behave:
19
+ * wait on the WAL/file lock instead of failing instantly, so we never race the app's own
20
+ * connection pool into a half-applied checkpoint. The app opens this DB with `busy_timeout=5s`
21
+ * (see codex-rs `state::runtime::base_sqlite_options`); we mirror that here.
22
+ */
23
+ function openStateDb(stateDbPath: string): Database {
24
+ const db = new Database(stateDbPath);
25
+ try {
26
+ db.exec("PRAGMA busy_timeout = 5000");
27
+ } catch {
28
+ /* best-effort: an older sqlite without busy_timeout still works, just less politely */
29
+ }
30
+ return db;
31
+ }
32
+
33
+ /**
34
+ * Append one JSONL line to a rollout using an O_APPEND handle, exactly like the Codex app's own
35
+ * metadata writer (`append_rollout_item_to_path` in codex-rs `rollout/src/recorder.rs`).
36
+ *
37
+ * Why append instead of rewriting line 1:
38
+ * - The app caches the live session's append handle and only reopens it when the handle is gone
39
+ * (codex-rs `RolloutWriterState::ensure_writer_open`). A temp+rename swap would orphan that
40
+ * handle; an in-place truncate would race the app's concurrent appends and clip new turns.
41
+ * - The app folds metadata by replaying every `session_meta` line in file order, last-writer-wins
42
+ * (codex-rs `apply_session_meta_from_item`), so a trailing `session_meta` overrides earlier ones.
43
+ * Real rollouts already contain multiple `session_meta` lines for this reason.
44
+ * O_APPEND makes each write land at EOF atomically, so it composes safely with the app appending
45
+ * concurrently. We do not touch mtime: a fresh mtime is correct here (the app uses mtime as the
46
+ * rollout's updated_at), and forcing it backwards could hide a real edit from list ordering.
47
+ */
48
+ function appendRolloutLine(path: string, line: string): void {
49
+ const fd = openSync(path, "a");
50
+ try {
51
+ const buf = Buffer.from(line.endsWith("\n") ? line : `${line}\n`, "utf8");
52
+ let offset = 0;
53
+ while (offset < buf.length) {
54
+ offset += writeSync(fd, buf, offset, buf.length - offset, null);
55
+ }
56
+ try { fsyncSync(fd); } catch { /* best-effort durability */ }
57
+ } finally {
58
+ closeSync(fd);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Patch the `model_provider` value inside the FIRST line of a rollout *in place, length-preserving*.
64
+ *
65
+ * Why this exists in addition to {@link appendRolloutLine}: Codex resolves a thread's provider via
66
+ * two different readers. The SQLite replay path folds every `session_meta` line last-writer-wins
67
+ * (covered by appending a trailing meta), but `read_session_meta_line` reads only the FIRST line
68
+ * and `update_thread_metadata` clones it when the app later writes git/memory-mode metadata
69
+ * (codex-rs `thread-store/src/local/update_thread_metadata.rs`). If the first line still says
70
+ * `opencodex` after a native restore, that clone re-appends `opencodex` and last-writer-wins
71
+ * resurrects the routed provider. So a durable restore must also fix line 1.
72
+ *
73
+ * Safety: Codex parses each rollout line as `serde_json::from_str(line.trim())`, which tolerates
74
+ * insignificant JSON whitespace. We therefore replace the provider value and pad the removed bytes
75
+ * with spaces so the line's byte length is unchanged. Equal length means we can write at offset 0
76
+ * with no truncate and no inode swap, so this composes safely with the app's cached append handle.
77
+ * Only length-preserving shrinks are handled (e.g. "opencodex" -> "openai"); callers that would
78
+ * grow the value fall back to append-only, which is correct for the opencodex direction.
79
+ *
80
+ * Returns true when line 1 was patched, false when it could not be done safely (missing file,
81
+ * non-`session_meta` first line, id mismatch, value already correct, or a length-growing change).
82
+ */
83
+ function patchFirstLineProviderInPlace(path: string, expectedId: string, provider: string): boolean {
84
+ if (!existsSync(path)) return false;
85
+ const fd = openSync(path, "r+");
86
+ try {
87
+ // Read the first line by growing the probe until we hit a newline. session_meta lines embed
88
+ // base_instructions and can be tens of KB; a fixed cap would silently skip the in-place patch
89
+ // (and fall back to append-only, re-opening the first-line-clone resurrection gap), so we read
90
+ // until the line actually ends rather than guessing a ceiling.
91
+ const CHUNK = 1 << 16;
92
+ const MAX_FIRST_LINE = 1 << 24; // 16 MiB hard stop so a newline-less/corrupt file can't OOM us.
93
+ let collected = Buffer.alloc(0);
94
+ let nlIndex = -1;
95
+ let pos = 0;
96
+ while (nlIndex === -1) {
97
+ const chunk = Buffer.alloc(CHUNK);
98
+ const read = readSync(fd, chunk, 0, CHUNK, pos);
99
+ if (read === 0) break; // EOF with no newline: single-line file, skip
100
+ collected = Buffer.concat([collected, chunk.subarray(0, read)]);
101
+ nlIndex = collected.indexOf(0x0a);
102
+ pos += read;
103
+ if (collected.length > MAX_FIRST_LINE) return false;
104
+ }
105
+ if (nlIndex === -1) return false; // no newline anywhere: skip
106
+ const firstLine = collected.subarray(0, nlIndex).toString("utf8");
107
+
108
+ const meta = parseSessionMetaLine(firstLine);
109
+ if (!meta) return false;
110
+ if (meta.record.payload.id !== expectedId) return false;
111
+ if (meta.record.payload.model_provider === provider) return false;
112
+
113
+ // Locate the exact `"model_provider":"<value>"` token (allowing whitespace after the colon).
114
+ const match = firstLine.match(/"model_provider"\s*:\s*"([^"\\]*)"/);
115
+ if (!match || match.index === undefined) return false;
116
+ const oldToken = match[0];
117
+ const newCore = `"model_provider":"${provider}"`;
118
+ if (Buffer.byteLength(newCore, "utf8") > Buffer.byteLength(oldToken, "utf8")) return false; // grow: not length-preserving
119
+ const pad = " ".repeat(Buffer.byteLength(oldToken, "utf8") - Buffer.byteLength(newCore, "utf8"));
120
+ const newToken = `${newCore}${pad}`;
121
+
122
+ const patchedLine = firstLine.slice(0, match.index) + newToken + firstLine.slice(match.index + oldToken.length);
123
+ // Length must be identical so the trailing bytes (newline + rest of file) are untouched.
124
+ if (Buffer.byteLength(patchedLine, "utf8") !== Buffer.byteLength(firstLine, "utf8")) return false;
125
+ // Sanity: the patched line must still parse and carry the new provider.
126
+ const reparsed = parseSessionMetaLine(patchedLine);
127
+ if (!reparsed || reparsed.record.payload.model_provider !== provider) return false;
128
+
129
+ const out = Buffer.from(patchedLine, "utf8");
130
+ let offset = 0;
131
+ while (offset < out.length) {
132
+ offset += writeSync(fd, out, offset, out.length - offset, offset);
133
+ }
134
+ try { fsyncSync(fd); } catch { /* best-effort durability */ }
135
+ return true;
136
+ } finally {
137
+ closeSync(fd);
138
+ }
139
+ }
140
+
17
141
  type CodexHistoryProvider = "openai" | "opencodex";
18
142
 
19
143
  export interface CodexHistorySyncResult {
@@ -92,24 +216,62 @@ function rememberOriginal(manifest: BackupManifest, row: ThreadRow): void {
92
216
  };
93
217
  }
94
218
 
95
- function updateSessionMeta(path: string, patch: { provider?: string; source?: string }): boolean {
96
- if (!path || !existsSync(path)) return false;
97
- const stat = statSync(path);
98
- const raw = readFileSync(path, "utf8");
99
- const newline = raw.indexOf("\n");
100
- const firstLine = newline === -1 ? raw : raw.slice(0, newline);
101
- const rest = newline === -1 ? "" : raw.slice(newline);
219
+ interface ParsedSessionMeta {
220
+ record: { type?: unknown; timestamp?: unknown; payload: { model_provider?: unknown; source?: unknown } & Record<string, unknown> };
221
+ }
102
222
 
223
+ /** Parse one JSONL line into a `session_meta` record, or null if it isn't one. */
224
+ function parseSessionMetaLine(line: string): ParsedSessionMeta | null {
103
225
  let parsed: unknown;
104
226
  try {
105
- parsed = JSON.parse(firstLine);
227
+ parsed = JSON.parse(line);
106
228
  } catch {
107
- return false;
229
+ return null;
108
230
  }
231
+ if (!parsed || typeof parsed !== "object") return null;
232
+ const record = parsed as ParsedSessionMeta["record"];
233
+ if (record.type !== "session_meta" || !record.payload || typeof record.payload !== "object") return null;
234
+ return { record };
235
+ }
109
236
 
110
- if (!parsed || typeof parsed !== "object") return false;
111
- const record = parsed as { type?: unknown; payload?: { model_provider?: unknown; source?: unknown } };
112
- if (record.type !== "session_meta" || !record.payload || typeof record.payload !== "object") return false;
237
+ /**
238
+ * Find the LAST `session_meta` line in a rollout, mirroring the app's last-writer-wins fold
239
+ * (codex-rs `apply_session_meta_from_item`). We base our patch on the most recent metadata so we
240
+ * never resurrect a stale provider that a later app-written `session_meta` already changed.
241
+ */
242
+ function readLatestSessionMeta(path: string): ParsedSessionMeta | null {
243
+ const raw = readFileSync(path, "utf8");
244
+ const lines = raw.split("\n");
245
+ for (let i = lines.length - 1; i >= 0; i--) {
246
+ const line = lines[i];
247
+ if (!line) continue;
248
+ if (!line.includes("\"session_meta\"")) continue;
249
+ const meta = parseSessionMetaLine(line);
250
+ if (meta) return meta;
251
+ }
252
+ return null;
253
+ }
254
+
255
+ /**
256
+ * Make a thread's rollout reflect a provider/source change by APPENDING a new `session_meta` line,
257
+ * rather than rewriting line 1. The appended line clones the latest metadata payload (so no field
258
+ * is accidentally reset to empty) and applies only the requested changes. Returns false when the
259
+ * rollout is missing, has no parseable `session_meta`, its latest `session_meta` belongs to a
260
+ * different thread id, or it already matches the desired values.
261
+ */
262
+ function updateSessionMeta(path: string, expectedId: string, patch: { provider?: string; source?: string }): boolean {
263
+ if (!path || !existsSync(path)) return false;
264
+
265
+ const latest = readLatestSessionMeta(path);
266
+ if (!latest) return false;
267
+ const record = latest.record;
268
+
269
+ // The app ignores `session_meta` lines whose payload id != the canonical thread id
270
+ // (codex-rs `apply_session_meta_from_item`). Forked rollouts can embed a source session's
271
+ // metadata, so an id-mismatched latest line means we'd be cloning the wrong thread's meta and
272
+ // appending a line the app would discard. Skip rather than write a no-op/misleading line.
273
+ const payloadId = record.payload.id;
274
+ if (typeof payloadId !== "string" || payloadId !== expectedId) return false;
113
275
 
114
276
  let changed = false;
115
277
  if (patch.provider !== undefined && record.payload.model_provider !== patch.provider) {
@@ -122,8 +284,19 @@ function updateSessionMeta(path: string, patch: { provider?: string; source?: st
122
284
  }
123
285
  if (!changed) return false;
124
286
 
125
- atomicWriteFile(path, `${JSON.stringify(record)}${rest}`);
126
- utimesSync(path, stat.atime, stat.mtime);
287
+ // Cover Codex's *other* provider reader: `read_session_meta_line` reads only line 1, and the
288
+ // app clones it when writing later git/memory-mode metadata. Appending alone leaves a stale
289
+ // line-1 provider that the clone would re-append, so for a length-preserving provider change we
290
+ // also patch line 1 in place (no inode swap, no truncate). Best-effort: when it can't be done
291
+ // safely (e.g. a length-growing change), the trailing append below is still correct for the
292
+ // SQLite replay path.
293
+ if (patch.provider !== undefined) {
294
+ try { patchFirstLineProviderInPlace(path, expectedId, patch.provider); } catch { /* best-effort line-1 patch */ }
295
+ }
296
+
297
+ // Refresh the line timestamp so the appended record reads as the newest metadata.
298
+ record.timestamp = new Date().toISOString();
299
+ appendRolloutLine(path, JSON.stringify(record));
127
300
  return true;
128
301
  }
129
302
 
@@ -155,7 +328,7 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu
155
328
  let files = 0;
156
329
  for (const row of rows) {
157
330
  try {
158
- if (updateSessionMeta(row.rollout_path, {
331
+ if (updateSessionMeta(row.rollout_path, row.id, {
159
332
  provider: "openai",
160
333
  source: row.source === "exec" ? "cli" : undefined,
161
334
  })) files++;
@@ -206,7 +379,7 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
206
379
  if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
207
380
  if (provider === "openai") return restoreCodexHistoryProvider(stateDbPath, backupPath);
208
381
 
209
- const db = new Database(stateDbPath);
382
+ const db = openStateDb(stateDbPath);
210
383
  try {
211
384
  const placeholders = RESUMABLE_SOURCES.map(() => "?").join(",");
212
385
  const openaiRows = db
@@ -234,14 +407,14 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
234
407
  let files = 0;
235
408
  for (const row of openaiRows) {
236
409
  try {
237
- if (updateSessionMeta(row.rollout_path, { provider: "opencodex" })) files++;
410
+ if (updateSessionMeta(row.rollout_path, row.id, { provider: "opencodex" })) files++;
238
411
  } catch {
239
412
  /* best-effort; keep DB migration moving even if one old rollout is malformed */
240
413
  }
241
414
  }
242
415
  for (const row of execRows) {
243
416
  try {
244
- if (updateSessionMeta(row.rollout_path, { source: "cli" })) files++;
417
+ if (updateSessionMeta(row.rollout_path, row.id, { source: "cli" })) files++;
245
418
  } catch {
246
419
  /* best-effort; keep DB migration moving even if one old rollout is malformed */
247
420
  }
@@ -281,7 +454,7 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
281
454
  const manifest = readBackup(backupPath, stateDbPath);
282
455
  const entries = Object.values(manifest.entries);
283
456
 
284
- const db = new Database(stateDbPath);
457
+ const db = openStateDb(stateDbPath);
285
458
  try {
286
459
  if (entries.length === 0) {
287
460
  const ejected = ejectRemainingOpencodexHistory(db);
@@ -292,7 +465,7 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
292
465
  for (const entry of entries) {
293
466
  const target = toNativeRestoreTarget(entry);
294
467
  try {
295
- if (updateSessionMeta(entry.rolloutPath, { provider: target.modelProvider, source: target.source })) files++;
468
+ if (updateSessionMeta(entry.rolloutPath, entry.id, { provider: target.modelProvider, source: target.source })) files++;
296
469
  } catch {
297
470
  /* best-effort; keep DB restore moving even if one rollout disappeared */
298
471
  }
@@ -325,7 +498,7 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
325
498
  export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): { rows: number; files: number } {
326
499
  try {
327
500
  if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
328
- const db = new Database(stateDbPath);
501
+ const db = openStateDb(stateDbPath);
329
502
  try {
330
503
  return ejectRemainingOpencodexHistory(db);
331
504
  } finally {
@@ -0,0 +1,242 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { CODEX_CONFIG_PATH } from "./codex-paths";
4
+ import { redactUserPath } from "./redact";
5
+
6
+ // Mirrors codex-rs core-plugins/src/marketplace.rs MARKETPLACE_MANIFEST_RELATIVE_PATHS.
7
+ // A marketplace root "resolves" only when one of these files exists under it.
8
+ const MARKETPLACE_MANIFEST_RELATIVE_PATHS = [
9
+ ".agents/plugins/marketplace.json",
10
+ ".claude-plugin/marketplace.json",
11
+ ] as const;
12
+
13
+ const OPENAI_BUNDLED_MARKETPLACE_NAME = "openai-bundled";
14
+
15
+ // Where the Codex desktop app's bundled plugins live, relative to an install
16
+ // root. Windows app-package paths embed the app version, so the actual install
17
+ // root is discovered by scanning candidate bases (LOCALAPPDATA, etc.) rather
18
+ // than hardcoded. The bundled marketplace dir is named after the marketplace.
19
+ const BUNDLED_MARKETPLACE_LEAF = join("plugins", "bundled-marketplaces", OPENAI_BUNDLED_MARKETPLACE_NAME);
20
+ const CODEX_APP_DIR_SEGMENTS = [join("Programs", "@openai", "codex"), join("Programs", "codex"), join("@openai", "codex"), "codex"] as const;
21
+
22
+ // Plugins the issue (#43) calls out. Treated as data, not as an authoritative
23
+ // allowlist: codex-rs only allowlists chrome/computer-use, but the diagnostic
24
+ // just reports presence, so listing browser here is informational only.
25
+ const COMMON_BUNDLED_PLUGINS = ["computer-use", "browser", "chrome"] as const;
26
+
27
+ export type CodexPluginsDiagnostic =
28
+ | { applicable: false; reason: string; summary: string }
29
+ | {
30
+ applicable: true;
31
+ stale: boolean;
32
+ marketplace: {
33
+ name: string;
34
+ present: boolean;
35
+ sourceType: string | null;
36
+ source: string | null;
37
+ resolvesToManifest: boolean;
38
+ currentBundledPath: string | null;
39
+ pathMismatch: boolean;
40
+ };
41
+ bundledPlugins: Array<{ id: string; configured: boolean }>;
42
+ suggestedRepair: string | null;
43
+ summary: string;
44
+ };
45
+
46
+ /** True when the table at `[marketplaces.<name>]` exists in the config text. */
47
+ function readMarketplaceTable(configText: string, name: string): Record<string, string> | null {
48
+ // Split on CRLF or LF: config.toml on Windows (the platform this diagnostic
49
+ // targets) uses CRLF, and a leftover \r would defeat the `$`-anchored regexes.
50
+ const lines = configText.split(/\r?\n/);
51
+ const header = new RegExp(`^\\s*\\[marketplaces\\.(?:"${escapeRegExp(name)}"|${escapeRegExp(name)})\\]\\s*(?:#.*)?$`);
52
+ let start = -1;
53
+ for (let i = 0; i < lines.length; i++) {
54
+ if (header.test(lines[i] ?? "")) { start = i + 1; break; }
55
+ }
56
+ if (start === -1) return null;
57
+
58
+ const table: Record<string, string> = {};
59
+ for (let i = start; i < lines.length; i++) {
60
+ const line = lines[i] ?? "";
61
+ if (/^\s*\[/.test(line)) break; // next table starts; stop
62
+ const m = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*("(?:\\.|[^"])*"|'[^']*'|[^#]+?)\s*(?:#.*)?$/);
63
+ if (!m) continue;
64
+ table[m[1]] = unquoteTomlValue(m[2].trim());
65
+ }
66
+ return table;
67
+ }
68
+
69
+ function unquoteTomlValue(raw: string): string {
70
+ if (raw.startsWith("\"")) {
71
+ try { return JSON.parse(raw) as string; } catch { return raw.slice(1, -1); }
72
+ }
73
+ if (raw.startsWith("'")) return raw.slice(1, -1);
74
+ return raw;
75
+ }
76
+
77
+ function escapeRegExp(value: string): string {
78
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
+ }
80
+
81
+ /** A directory is a bundled-marketplace root when it holds a supported manifest. */
82
+ function dirHasManifest(dir: string): boolean {
83
+ return MARKETPLACE_MANIFEST_RELATIVE_PATHS.some(rel => existsSync(join(dir, rel)));
84
+ }
85
+
86
+ /**
87
+ * Locate the bundled `openai-bundled` marketplace dir under the installed Codex
88
+ * desktop app on Windows. Windows app paths embed the app version, so we scan
89
+ * candidate install bases for a versioned app dir whose
90
+ * `plugins/bundled-marketplaces/openai-bundled` holds a manifest. Returns the
91
+ * newest matching dir (by mtime) or null. Filesystem access is injectable for
92
+ * tests so a Windows layout can be exercised on any OS.
93
+ */
94
+ export function locateCurrentBundledMarketplace(
95
+ options: {
96
+ env?: NodeJS.ProcessEnv;
97
+ listDir?: (dir: string) => string[];
98
+ isManifestRoot?: (dir: string) => boolean;
99
+ mtimeOf?: (dir: string) => number;
100
+ } = {},
101
+ ): string | null {
102
+ const env = options.env ?? process.env;
103
+ const listDir = options.listDir ?? ((dir: string) => {
104
+ try { return readdirSync(dir); } catch { return []; }
105
+ });
106
+ const isManifestRoot = options.isManifestRoot ?? dirHasManifest;
107
+ const mtimeOf = options.mtimeOf ?? ((dir: string) => {
108
+ try { return statSync(dir).mtimeMs; } catch { return 0; }
109
+ });
110
+
111
+ const bases = [env.LOCALAPPDATA, env.PROGRAMFILES, env["ProgramFiles(x86)"], env.APPDATA]
112
+ .filter((b): b is string => typeof b === "string" && b.length > 0);
113
+
114
+ const candidates: string[] = [];
115
+ for (const base of bases) {
116
+ for (const seg of CODEX_APP_DIR_SEGMENTS) {
117
+ const appRoot = join(base, seg);
118
+ // Direct (unversioned) layout.
119
+ const direct = join(appRoot, BUNDLED_MARKETPLACE_LEAF);
120
+ if (isManifestRoot(direct)) candidates.push(direct);
121
+ // Versioned layout: appRoot/<version>/plugins/bundled-marketplaces/openai-bundled
122
+ for (const child of listDir(appRoot)) {
123
+ const versioned = join(appRoot, child, BUNDLED_MARKETPLACE_LEAF);
124
+ if (isManifestRoot(versioned)) candidates.push(versioned);
125
+ }
126
+ }
127
+ }
128
+ if (candidates.length === 0) return null;
129
+ candidates.sort((a, b) => mtimeOf(b) - mtimeOf(a));
130
+ return candidates[0] ?? null;
131
+ }
132
+
133
+ /** Normalize a path for comparison: lowercase + unify separators (Windows is case-insensitive). */
134
+ function normalizePathForCompare(path: string): string {
135
+ return path.replace(/[\\/]+/g, "\\").replace(/\\+$/, "").toLowerCase();
136
+ }
137
+
138
+ /** A local marketplace `source` resolves when it holds a supported manifest. */
139
+ function sourceResolvesToManifest(source: string): boolean {
140
+ if (!isAbsolute(source)) return false;
141
+ if (!existsSync(source)) return false;
142
+ return MARKETPLACE_MANIFEST_RELATIVE_PATHS.some(rel => existsSync(join(source, rel)));
143
+ }
144
+
145
+ /**
146
+ * Read-only diagnostic for the Codex `openai-bundled` plugin marketplace.
147
+ *
148
+ * Only meaningful on Windows, where app-package paths embed the app version and
149
+ * go stale after an update. On other platforms it reports "not applicable".
150
+ * NEVER mutates config.toml, never invokes `codex plugin marketplace add`.
151
+ */
152
+ export function diagnoseCodexBundledPlugins(
153
+ options: {
154
+ platform?: NodeJS.Platform;
155
+ configPath?: string;
156
+ locateCurrent?: () => string | null;
157
+ } = {},
158
+ ): CodexPluginsDiagnostic {
159
+ const platform = options.platform ?? process.platform;
160
+ if (platform !== "win32") {
161
+ return {
162
+ applicable: false,
163
+ reason: "not_windows",
164
+ summary: "not applicable (bundled-marketplace staleness is Windows-specific)",
165
+ };
166
+ }
167
+
168
+ const configPath = options.configPath ?? CODEX_CONFIG_PATH;
169
+ let configText: string;
170
+ try {
171
+ configText = readFileSync(configPath, "utf8");
172
+ } catch {
173
+ return {
174
+ applicable: false,
175
+ reason: "config_unreadable",
176
+ summary: "not applicable (Codex config.toml not found or unreadable)",
177
+ };
178
+ }
179
+
180
+ const table = readMarketplaceTable(configText, OPENAI_BUNDLED_MARKETPLACE_NAME);
181
+ const present = table !== null;
182
+ const sourceType = table?.source_type ?? null;
183
+ const source = table?.source ?? null;
184
+ const isLocal = sourceType === "local" && !!source;
185
+ const resolvesToManifest = isLocal ? sourceResolvesToManifest(source as string) : false;
186
+
187
+ // Locate the bundled marketplace under the currently installed Codex app, so
188
+ // we can tell "registered path differs from the live app path" (the Windows
189
+ // app-update staleness signal) apart from a merely missing manifest.
190
+ const locateCurrent = options.locateCurrent ?? (() => locateCurrentBundledMarketplace());
191
+ const currentBundledPath = locateCurrent();
192
+ const pathMismatch = !!(
193
+ currentBundledPath && source && isLocal &&
194
+ normalizePathForCompare(currentBundledPath) !== normalizePathForCompare(source)
195
+ );
196
+
197
+ // Stale = a registered local bundled marketplace whose source no longer
198
+ // resolves to a manifest, OR whose registered path differs from the live
199
+ // app's bundled path (the Windows app-update signal). A missing marketplace
200
+ // is "not stale" but flagged separately by `present: false`.
201
+ const stale = present && isLocal && (!resolvesToManifest || pathMismatch);
202
+ // Present but not a usable local entry (wrong source_type or empty source):
203
+ // not "stale" in the app-update sense, but it must NOT be reported as healthy.
204
+ const malformed = present && !isLocal;
205
+
206
+ const bundledPlugins = COMMON_BUNDLED_PLUGINS.map(id => ({
207
+ id,
208
+ configured: new RegExp(`\\[plugins\\.(?:"${escapeRegExp(`${id}@${OPENAI_BUNDLED_MARKETPLACE_NAME}`)}")\\]`).test(configText),
209
+ }));
210
+
211
+ const repairTarget = currentBundledPath ?? `<current ${OPENAI_BUNDLED_MARKETPLACE_NAME} path under the installed Codex app>`;
212
+ const suggestedRepair = (stale || pathMismatch)
213
+ ? `codex plugin marketplace add ${currentBundledPath ? redactUserPath(currentBundledPath) : repairTarget}`
214
+ : null;
215
+
216
+ const summary = !present
217
+ ? `no [marketplaces.${OPENAI_BUNDLED_MARKETPLACE_NAME}] entry in Codex config`
218
+ : malformed
219
+ ? `[marketplaces.${OPENAI_BUNDLED_MARKETPLACE_NAME}] is present but not a usable local source (source_type/source missing)`
220
+ : !resolvesToManifest
221
+ ? `stale: registered ${OPENAI_BUNDLED_MARKETPLACE_NAME} source no longer resolves to a marketplace manifest`
222
+ : pathMismatch
223
+ ? `stale: registered ${OPENAI_BUNDLED_MARKETPLACE_NAME} path differs from the installed Codex app's bundled path`
224
+ : `ok: ${OPENAI_BUNDLED_MARKETPLACE_NAME} marketplace resolves`;
225
+
226
+ return {
227
+ applicable: true,
228
+ stale,
229
+ marketplace: {
230
+ name: OPENAI_BUNDLED_MARKETPLACE_NAME,
231
+ present,
232
+ sourceType,
233
+ source: source ? redactUserPath(source) : null,
234
+ resolvesToManifest,
235
+ currentBundledPath: currentBundledPath ? redactUserPath(currentBundledPath) : null,
236
+ pathMismatch,
237
+ },
238
+ bundledPlugins,
239
+ suggestedRepair,
240
+ summary,
241
+ };
242
+ }
package/src/config.ts CHANGED
@@ -99,6 +99,7 @@ const configSchema = z.object({
99
99
  providers: z.record(z.string(), providerConfigSchema),
100
100
  defaultProvider: z.string().min(1).default("openai"),
101
101
  providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
102
+ contextCapValue: z.number().int().positive().optional(),
102
103
  }).passthrough().superRefine((config, ctx) => {
103
104
  for (const name of Object.keys(config.providers)) {
104
105
  if (!isValidProviderName(name)) {