@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.
- package/gui/dist/assets/index-CgCKvnPc.js +9 -0
- package/gui/dist/assets/{index-CSI5RHdZ.css → index-DIBiVVC0.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/azure.ts +2 -2
- package/src/adapters/base.ts +6 -1
- package/src/adapters/google-antigravity-replay.ts +136 -0
- package/src/adapters/google-antigravity-wire.ts +94 -0
- package/src/adapters/google-errors.ts +95 -0
- package/src/adapters/google-http.ts +109 -0
- package/src/adapters/google-tool-schema.ts +89 -0
- package/src/adapters/google-truncation.ts +13 -0
- package/src/adapters/google.ts +163 -20
- package/src/adapters/kiro-tools.ts +55 -2
- package/src/cli-status.ts +4 -0
- package/src/cli.ts +7 -0
- package/src/codex-history-provider.ts +195 -22
- package/src/codex-plugins-doctor.ts +242 -0
- package/src/config.ts +1 -0
- package/src/lib/gcp-adc.ts +292 -0
- package/src/oauth/google-antigravity.ts +225 -0
- package/src/oauth/index.ts +19 -1
- package/src/oauth/store.ts +1 -0
- package/src/oauth/types.ts +2 -0
- package/src/provider-context-cap.ts +30 -1
- package/src/providers/antigravity-models.ts +30 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/registry.ts +7 -0
- package/src/redact.ts +26 -0
- package/src/router.ts +6 -0
- package/src/server.ts +45 -9
- package/src/types.ts +12 -0
- package/src/usage-summary.ts +30 -3
- package/src/web-search/loop.ts +1 -1
- package/gui/dist/assets/index-Ct8koBOe.js +0 -9
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
|
227
|
+
parsed = JSON.parse(line);
|
|
106
228
|
} catch {
|
|
107
|
-
return
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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)) {
|