@bitkyc08/opencodex 2.20.0 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/AGENTS_INSTALL.md +32 -0
  2. package/README.md +1 -1
  3. package/gui/dist/assets/{index-DSK3S5HY.js → index-ClEcVlFO.js} +43 -17
  4. package/gui/dist/assets/index-DQsMZzI5.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic.ts +2 -28
  8. package/src/adapters/google.ts +31 -3
  9. package/src/adapters/openai-chat.ts +25 -8
  10. package/src/adapters/responses-tool-schema.ts +67 -0
  11. package/src/bridge.ts +15 -2
  12. package/src/claude/agents-inject.ts +2 -2
  13. package/src/claude/gateway-cache.ts +41 -4
  14. package/src/cli/claude.ts +1 -1
  15. package/src/cli/codex-log-guard-doctor.ts +103 -0
  16. package/src/cli/dispatch.ts +7 -1
  17. package/src/cli/help.ts +1 -1
  18. package/src/cli/models.ts +16 -6
  19. package/src/cli/observe.ts +38 -2
  20. package/src/cli/registry.ts +2 -1
  21. package/src/cli/v2.ts +34 -1
  22. package/src/codex/app-server-processes.ts +46 -26
  23. package/src/codex/catalog/effort.ts +49 -1
  24. package/src/codex/catalog/parsing.ts +64 -4
  25. package/src/codex/catalog/provider-fetch.ts +12 -0
  26. package/src/codex/catalog/sync.ts +14 -1
  27. package/src/codex/convergence.ts +2 -0
  28. package/src/codex/inject.ts +3 -3
  29. package/src/codex/log-guard/inspect.ts +506 -0
  30. package/src/codex/log-guard/lock.ts +150 -0
  31. package/src/codex/log-guard/maintenance.ts +403 -0
  32. package/src/codex/log-guard/path-safety.ts +39 -0
  33. package/src/codex/log-guard/policy.ts +44 -0
  34. package/src/codex/log-guard/processes.ts +205 -0
  35. package/src/codex/log-guard/protection.ts +489 -0
  36. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  37. package/src/codex/paths.ts +5 -0
  38. package/src/codex/plugins-doctor.ts +1 -1
  39. package/src/codex/project-config-warnings.ts +2 -2
  40. package/src/generated/compatibility-version.json +93 -45
  41. package/src/images/loop.ts +15 -5
  42. package/src/providers/antigravity-models.ts +11 -1
  43. package/src/providers/model-discovery.ts +94 -6
  44. package/src/providers/quota.ts +159 -0
  45. package/src/providers/registry.ts +20 -1
  46. package/src/providers/slug-codec.ts +29 -0
  47. package/src/responses/custom-tool-compat.ts +4 -1
  48. package/src/responses/parser.ts +7 -1
  49. package/src/responses/provider-opaque-metadata.ts +73 -0
  50. package/src/responses/schema.ts +6 -0
  51. package/src/router.ts +12 -4
  52. package/src/routing/capability.ts +32 -17
  53. package/src/server/auth-cors.ts +42 -6
  54. package/src/server/index.ts +1 -0
  55. package/src/server/management/agent-settings-routes.ts +20 -2
  56. package/src/server/management/context.ts +15 -0
  57. package/src/server/management/model-routes.ts +12 -3
  58. package/src/server/management/storage-log-guard-routes.ts +186 -0
  59. package/src/server/management-api.ts +3 -1
  60. package/src/server/responses/core.ts +13 -0
  61. package/src/server/system-env.ts +1 -1
  62. package/src/types.ts +24 -2
  63. package/src/web-search/loop.ts +21 -5
  64. package/gui/dist/assets/index-DF_UFrGS.css +0 -1
@@ -0,0 +1,489 @@
1
+ import { lstatSync, realpathSync } from "node:fs";
2
+ import { pathToFileURL } from "node:url";
3
+ import { Database, constants as sqliteConstants } from "bun:sqlite";
4
+
5
+ import { getCodexHome, resolveCodexLogsDbPath } from "../paths";
6
+ import { hasCurrentLogsSchema, inspectCodexLogs, type CodexLogGuardInspection } from "./inspect";
7
+ import { withCodexLogGuardLock, type CodexLogGuardLockOutcome } from "./lock";
8
+ import { sameLogGuardPathIdentity } from "./path-safety";
9
+ import { isSqliteBusy } from "./sqlite-errors";
10
+ import {
11
+ readCodexLogGuardMode,
12
+ writeCodexLogGuardMode,
13
+ type CodexLogGuardMode,
14
+ } from "./policy";
15
+ import {
16
+ listRunningCodexProcesses,
17
+ type CodexWriterProcessCheck,
18
+ } from "./processes";
19
+
20
+ export { type CodexLogGuardMode } from "./policy";
21
+
22
+ const IMMUTABLE_READONLY_FLAGS = sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI;
23
+ const COMPAT_TRIGGER = "opencodex_log_guard_compat_v1";
24
+ const QUIET_TRIGGER = "opencodex_log_guard_quiet_v1";
25
+ const OWNED_TRIGGER_NAMES = [COMPAT_TRIGGER, QUIET_TRIGGER] as const;
26
+
27
+ const CURRENT_LOG_COLUMNS = [
28
+ "id",
29
+ "ts",
30
+ "ts_nanos",
31
+ "level",
32
+ "target",
33
+ "feedback_log_body",
34
+ "module_path",
35
+ "file",
36
+ "line",
37
+ "thread_id",
38
+ "process_uuid",
39
+ "estimated_bytes",
40
+ ] as const;
41
+
42
+ /**
43
+ * Versioned compatibility policy pinned to the current upstream Codex persistent
44
+ * log filters researched for Log Guard v1. It intentionally preserves unrelated
45
+ * TRACE rows rather than assuming all TRACE diagnostics are disposable.
46
+ */
47
+ /**
48
+ * Upstream configures these filters with `Targets::with_target`, which matches a
49
+ * target and every module path BENEATH it: `hyper_util` also covers
50
+ * `hyper_util::client::legacy::pool`, and `codex_api::sse` also covers
51
+ * `codex_api::sse::responses`. Exact equality reproduced only the parent, so the
52
+ * high-volume child targets — the ones that actually fill the database — kept
53
+ * writing while compat mode reported itself active.
54
+ *
55
+ * The comparison uses `substr`, not `LIKE`. SQLite's `LIKE` is ASCII
56
+ * case-insensitive by default, so a `LIKE` form would also suppress
57
+ * `HYPER_UTIL::child` — Rust target paths are case-sensitive, and silently
58
+ * dropping a differently-cased target is a wrong answer, not a safe default.
59
+ * `substr(NEW.target, 1, N) = 'X::'` is a plain case-sensitive comparison with
60
+ * no wildcard metacharacters to escape, which also removes the `_`/`%` hazard
61
+ * that `LIKE` would have required an ESCAPE clause to contain.
62
+ *
63
+ * The `'::'` boundary is deliberate and NARROWER than upstream's raw prefix
64
+ * rule: `Targets::with_target("hyper_util")` would also match a sibling crate
65
+ * named `hyper_utilities`. Suppressing an unrelated crate's logs is worse for
66
+ * a guard that silently discards rows, so this matches the module-descendant
67
+ * relation instead. The existing regression pins `hyper_utilities` as
68
+ * preserved.
69
+ *
70
+ * `opentelemetry_sdk` stays exact because upstream registers it with exact
71
+ * equality rather than a prefix filter.
72
+ */
73
+ function targetOrDescendant(target: string): string {
74
+ const prefix = `${target}::`;
75
+ return `(NEW.target = '${target}' OR substr(NEW.target, 1, ${prefix.length}) = '${prefix}')`;
76
+ }
77
+
78
+ function anyTargetOrDescendant(targets: readonly string[]): string {
79
+ return `(${targets.map(targetOrDescendant).join(" OR ")})`;
80
+ }
81
+
82
+ const COMPAT_TRIGGER_SQL = `CREATE TRIGGER ${COMPAT_TRIGGER}
83
+ BEFORE INSERT ON logs
84
+ WHEN
85
+ NEW.target = 'log'
86
+ OR NEW.target = 'codex_otel.log_only'
87
+ OR NEW.target = 'codex_otel.trace_safe'
88
+ OR NEW.target = 'codex_api::responses_websocket_timing'
89
+ OR NEW.target = 'codex_core::post_sampling_token_estimate'
90
+ OR (${targetOrDescendant("hyper_util")} AND upper(NEW.level) IN ('TRACE', 'DEBUG', 'INFO'))
91
+ OR (${anyTargetOrDescendant(["codex_rmcp_client", "rmcp"])} AND upper(NEW.level) IN ('TRACE', 'DEBUG'))
92
+ OR (${anyTargetOrDescendant([
93
+ "codex_http_client::transport",
94
+ "codex_api::sse",
95
+ "codex_tui::streaming::controller",
96
+ "codex_tui::streaming::table_holdback",
97
+ ])} AND upper(NEW.level) = 'TRACE')
98
+ OR (NEW.target = 'opentelemetry_sdk' AND upper(NEW.level) IN ('TRACE', 'DEBUG'))
99
+ BEGIN
100
+ SELECT RAISE(IGNORE);
101
+ END`;
102
+
103
+ const QUIET_TRIGGER_SQL = `CREATE TRIGGER ${QUIET_TRIGGER}
104
+ BEFORE INSERT ON logs
105
+ WHEN upper(NEW.level) = 'TRACE'
106
+ BEGIN
107
+ SELECT RAISE(IGNORE);
108
+ END`;
109
+
110
+ const SQL_BY_MODE: Record<Exclude<CodexLogGuardMode, "off">, string> = {
111
+ compat: COMPAT_TRIGGER_SQL,
112
+ quiet: QUIET_TRIGGER_SQL,
113
+ };
114
+
115
+ export type CodexLogGuardObservedMode = CodexLogGuardMode | "collision";
116
+ export type CodexLogGuardProtectionState = "off" | "active" | "drifted" | "unsupported" | "unknown";
117
+
118
+ export interface CodexLogGuardProtectionSummary {
119
+ desiredMode: CodexLogGuardMode;
120
+ observedMode: CodexLogGuardObservedMode;
121
+ state: CodexLogGuardProtectionState;
122
+ }
123
+
124
+ export type CodexLogGuardStatus = CodexLogGuardInspection & {
125
+ protection: CodexLogGuardProtectionSummary;
126
+ };
127
+
128
+ export type CodexLogGuardMutationError =
129
+ | "unsupported_schema"
130
+ | "codex_running"
131
+ | "process_enumeration_failed"
132
+ | "trigger_collision"
133
+ | "unsafe_path"
134
+ | "busy"
135
+ | "database_error"
136
+ | "config_write_failed";
137
+
138
+ export type CodexLogGuardMutationResult =
139
+ | { ok: true; status: CodexLogGuardStatus }
140
+ | { ok: false; error: CodexLogGuardMutationError };
141
+
142
+ export interface CodexLogGuardProtectionDeps {
143
+ codexHome?: string;
144
+ processCheck?: () => CodexWriterProcessCheck;
145
+ readDesiredMode?: () => CodexLogGuardMode;
146
+ writeDesiredMode?: (mode: CodexLogGuardMode) => void;
147
+ withLock?: <T>(
148
+ canonicalCodexHome: string,
149
+ canonicalLogsDbPath: string,
150
+ work: () => T,
151
+ ) => CodexLogGuardLockOutcome<T>;
152
+ }
153
+
154
+ interface TriggerRow {
155
+ name: string;
156
+ sql: string | null;
157
+ }
158
+ interface ColumnRow { name: string }
159
+ interface OwnedTriggerSnapshot { name: string; sql: string }
160
+
161
+ type LockedMutationResult =
162
+ | { ok: true }
163
+ | { ok: false; error: CodexLogGuardMutationError };
164
+
165
+ function normalizeSql(sql: string | null | undefined): string {
166
+ return (sql ?? "").trim().replace(/;\s*$/, "").replace(/\s+/g, " ");
167
+ }
168
+
169
+ function expectedSql(mode: Exclude<CodexLogGuardMode, "off">): string {
170
+ return normalizeSql(SQL_BY_MODE[mode]);
171
+ }
172
+
173
+ function ownedModeForRow(row: TriggerRow): Exclude<CodexLogGuardMode, "off"> | null {
174
+ if (row.name === COMPAT_TRIGGER && normalizeSql(row.sql) === expectedSql("compat")) return "compat";
175
+ if (row.name === QUIET_TRIGGER && normalizeSql(row.sql) === expectedSql("quiet")) return "quiet";
176
+ return null;
177
+ }
178
+
179
+ function queryReservedTriggers(db: Database): TriggerRow[] {
180
+ const placeholders = OWNED_TRIGGER_NAMES.map(() => "?").join(", ");
181
+ return db.query<TriggerRow, string[]>(
182
+ `SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND name IN (${placeholders}) ORDER BY name`,
183
+ ).all(...OWNED_TRIGGER_NAMES);
184
+ }
185
+
186
+ function observeTriggers(db: Database): CodexLogGuardObservedMode {
187
+ const rows = queryReservedTriggers(db);
188
+ if (rows.length === 0) return "off";
189
+ const modes = rows.map(ownedModeForRow);
190
+ if (modes.some(mode => mode === null)) return "collision";
191
+ const unique = new Set(modes);
192
+ return unique.size === 1 && rows.length === 1 ? modes[0]! : "collision";
193
+ }
194
+
195
+ function exactCurrentSchema(db: Database): boolean {
196
+ // Delegates to the inspector's predicate so the locked recheck is exactly as
197
+ // strict as the compatibility report. Column names alone let a schema change
198
+ // between inspection and the locked write slip a mutation onto a database the
199
+ // inspector calls monitor-only.
200
+ return hasCurrentLogsSchema(db);
201
+ }
202
+
203
+ /**
204
+ * Read trigger metadata with the same immutable/checkpointed semantics as PR 1
205
+ * diagnostics. A status GET must never participate in Codex's SQLite WAL/SHM
206
+ * protocol or materialise sidecars merely to report protection state.
207
+ */
208
+ function openReadOnly(databasePath: string): Database {
209
+ const uri = `${pathToFileURL(databasePath).href}?immutable=1`;
210
+ return new Database(uri, IMMUTABLE_READONLY_FLAGS);
211
+ }
212
+
213
+ function openReadWrite(databasePath: string): Database {
214
+ // READWRITE without CREATE: a missing/moved canonical DB is a refusal, not a
215
+ // reason for OpenCodex to materialise a new foreign database.
216
+ return new Database(databasePath, sqliteConstants.SQLITE_OPEN_READWRITE);
217
+ }
218
+
219
+ function databasePathIsSafe(databasePath: string): boolean {
220
+ try {
221
+ const stat = lstatSync(databasePath);
222
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
223
+ return sameLogGuardPathIdentity(realpathSync.native(databasePath), databasePath);
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ function protectionSummary(
230
+ inspection: CodexLogGuardInspection,
231
+ desiredMode: CodexLogGuardMode,
232
+ observedMode: CodexLogGuardObservedMode,
233
+ ): CodexLogGuardProtectionSummary {
234
+ if (inspection.capabilities.protection.state !== "supported") {
235
+ return { desiredMode, observedMode, state: "unsupported" };
236
+ }
237
+ if (observedMode === "collision") return { desiredMode, observedMode, state: "unknown" };
238
+ if (desiredMode === "off" && observedMode === "off") {
239
+ return { desiredMode, observedMode, state: "off" };
240
+ }
241
+ if (desiredMode !== "off" && desiredMode === observedMode) {
242
+ return { desiredMode, observedMode, state: "active" };
243
+ }
244
+ return { desiredMode, observedMode, state: "drifted" };
245
+ }
246
+
247
+ function inspectionDeps(deps: CodexLogGuardProtectionDeps): { codexHome?: string } {
248
+ return deps.codexHome ? { codexHome: deps.codexHome } : {};
249
+ }
250
+
251
+ export function getCodexLogGuardProtectionStatus(
252
+ deps: CodexLogGuardProtectionDeps = {},
253
+ ): CodexLogGuardStatus {
254
+ const codexHome = deps.codexHome ?? getCodexHome();
255
+ const inspection = inspectCodexLogs({ codexHome });
256
+ const databasePath = resolveCodexLogsDbPath({ codexHome });
257
+ const desiredMode = (deps.readDesiredMode ?? readCodexLogGuardMode)();
258
+ let observedMode: CodexLogGuardObservedMode = inspection.schema.state === "compatible" ? "collision" : "off";
259
+
260
+ if (inspection.schema.state === "compatible" && databasePathIsSafe(databasePath)) {
261
+ try {
262
+ const db = openReadOnly(databasePath);
263
+ try { observedMode = observeTriggers(db); }
264
+ finally { db.close(); }
265
+ } catch {
266
+ observedMode = "collision";
267
+ }
268
+ }
269
+
270
+ return {
271
+ ...inspection,
272
+ protection: protectionSummary(inspection, desiredMode, observedMode),
273
+ };
274
+ }
275
+
276
+ function successfulMutationStatus(
277
+ codexHome: string,
278
+ mode: CodexLogGuardMode,
279
+ ): CodexLogGuardStatus {
280
+ const inspection = inspectCodexLogs({ codexHome });
281
+ const state: CodexLogGuardProtectionState = inspection.capabilities.protection.state === "supported"
282
+ ? (mode === "off" ? "off" : "active")
283
+ : "unsupported";
284
+ return {
285
+ ...inspection,
286
+ protection: { desiredMode: mode, observedMode: mode, state },
287
+ };
288
+ }
289
+
290
+ function processRefusal(check: CodexWriterProcessCheck): CodexLogGuardMutationError | null {
291
+ if (check.state === "unknown") return "process_enumeration_failed";
292
+ if (check.processes.length > 0) return "codex_running";
293
+ return null;
294
+ }
295
+
296
+ function mutateOwnedTrigger(
297
+ databasePath: string,
298
+ mode: CodexLogGuardMode,
299
+ ): { ok: true; previousTriggers: readonly OwnedTriggerSnapshot[] } | { ok: false; error: CodexLogGuardMutationError } {
300
+ let db: Database | undefined;
301
+ let transactionOpen = false;
302
+ try {
303
+ if (!databasePathIsSafe(databasePath)) return { ok: false, error: "unsafe_path" };
304
+ db = openReadWrite(databasePath);
305
+ db.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
306
+ transactionOpen = true;
307
+ if (!exactCurrentSchema(db)) {
308
+ // Same reasoning as the caller's gate: installing into an unrecognized
309
+ // schema is refused, but removing a trigger we installed ourselves stays
310
+ // available so a schema upgrade cannot strand it.
311
+ if (mode !== "off") {
312
+ db.exec("ROLLBACK");
313
+ transactionOpen = false;
314
+ return { ok: false, error: "unsupported_schema" };
315
+ }
316
+ }
317
+
318
+ const rows = queryReservedTriggers(db);
319
+ const modes = rows.map(ownedModeForRow);
320
+ if (modes.some(item => item === null)) {
321
+ db.exec("ROLLBACK");
322
+ transactionOpen = false;
323
+ return { ok: false, error: "trigger_collision" };
324
+ }
325
+ const previousTriggers: OwnedTriggerSnapshot[] = rows.map(row => ({
326
+ name: row.name,
327
+ sql: row.sql!,
328
+ }));
329
+
330
+ for (const row of rows) {
331
+ // Name is from our fixed allow-list; never interpolate arbitrary sqlite_master data.
332
+ db.exec(`DROP TRIGGER ${row.name}`);
333
+ }
334
+ if (mode !== "off") db.exec(SQL_BY_MODE[mode]);
335
+
336
+ const observed = observeTriggers(db);
337
+ if (observed !== mode) throw new Error("log_guard_trigger_verification_failed");
338
+ db.exec("COMMIT");
339
+ transactionOpen = false;
340
+ return { ok: true, previousTriggers };
341
+ } catch (error) {
342
+ if (transactionOpen) {
343
+ try { db?.exec("ROLLBACK"); } catch { /* close releases the transaction */ }
344
+ }
345
+ if (isSqliteBusy(error)) return { ok: false, error: "busy" };
346
+ return { ok: false, error: "database_error" };
347
+ } finally {
348
+ try { db?.close(); } catch { /* mutation already settled */ }
349
+ }
350
+ }
351
+
352
+ function restoreOwnedTriggers(databasePath: string, previousTriggers: readonly OwnedTriggerSnapshot[]): void {
353
+ // Best-effort compensation only. Failure is deliberately not hidden by
354
+ // claiming success; the caller returns config_write_failed and status will
355
+ // expose any remaining drift on the next read.
356
+ let db: Database | undefined;
357
+ let transactionOpen = false;
358
+ try {
359
+ if (!databasePathIsSafe(databasePath)) return;
360
+ db = openReadWrite(databasePath);
361
+ db.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
362
+ transactionOpen = true;
363
+ if (!exactCurrentSchema(db)) {
364
+ db.exec("ROLLBACK");
365
+ transactionOpen = false;
366
+ return;
367
+ }
368
+
369
+ const current = queryReservedTriggers(db);
370
+ if (current.some(row => ownedModeForRow(row) === null)) {
371
+ db.exec("ROLLBACK");
372
+ transactionOpen = false;
373
+ return;
374
+ }
375
+ for (const row of current) db.exec(`DROP TRIGGER ${row.name}`);
376
+
377
+ for (const trigger of previousTriggers) {
378
+ if (ownedModeForRow(trigger) === null) throw new Error("invalid_owned_trigger_snapshot");
379
+ db.exec(trigger.sql);
380
+ }
381
+
382
+ const restored = queryReservedTriggers(db);
383
+ const expected = new Map(previousTriggers.map(row => [row.name, normalizeSql(row.sql)]));
384
+ if (restored.length !== previousTriggers.length
385
+ || restored.some(row => expected.get(row.name) !== normalizeSql(row.sql))) {
386
+ throw new Error("log_guard_trigger_restore_verification_failed");
387
+ }
388
+ db.exec("COMMIT");
389
+ transactionOpen = false;
390
+ } catch {
391
+ if (transactionOpen) {
392
+ try { db?.exec("ROLLBACK"); } catch { /* close releases the transaction */ }
393
+ }
394
+ } finally {
395
+ try { db?.close(); } catch { /* compensation already settled */ }
396
+ }
397
+ }
398
+
399
+ function performMutation(
400
+ requestedMode: CodexLogGuardMode | (() => CodexLogGuardMode),
401
+ deps: CodexLogGuardProtectionDeps,
402
+ ): CodexLogGuardMutationResult {
403
+ const codexHome = deps.codexHome ?? getCodexHome();
404
+ const inspection = inspectCodexLogs({ codexHome });
405
+ const databasePath = resolveCodexLogsDbPath({ codexHome });
406
+ // Removal must not be gated on the schema still being recognized. A Codex
407
+ // upgrade that changes the logs schema would otherwise strand an installed
408
+ // trigger: Protect is refused (correctly), but so is Disable, leaving the
409
+ // user with an active OpenCodex trigger and no in-product way to remove it.
410
+ // Installing into an unknown schema stays refused; taking our own trigger
411
+ // back out is always allowed.
412
+ const removingProtection = typeof requestedMode !== "function" && requestedMode === "off";
413
+ if (!removingProtection && inspection.capabilities.protection.state !== "supported") {
414
+ return { ok: false, error: "unsupported_schema" };
415
+ }
416
+ if (!databasePathIsSafe(databasePath)) return { ok: false, error: "unsafe_path" };
417
+
418
+ const checkProcesses = deps.processCheck ?? listRunningCodexProcesses;
419
+ const firstRefusal = processRefusal(checkProcesses());
420
+ if (firstRefusal) return { ok: false, error: firstRefusal };
421
+
422
+ const withLock = deps.withLock ?? withCodexLogGuardLock;
423
+ const writeDesired = deps.writeDesiredMode ?? writeCodexLogGuardMode;
424
+ let locked: CodexLogGuardLockOutcome<LockedMutationResult>;
425
+ // Repair passes a resolver instead of a value: its target mode must be read
426
+ // INSIDE L. Reading it before acquiring the lock let a Disable complete in
427
+ // the gap, after which the stale Repair reinstalled protection and reported
428
+ // success — the caller saw an honest "off" and got "compat".
429
+ let effectiveMode: CodexLogGuardMode = typeof requestedMode === "function" ? "off" : requestedMode;
430
+ try {
431
+ locked = withLock(codexHome, databasePath, () => {
432
+ // Recheck after acquiring L so a Codex process that starts during lock
433
+ // acquisition cannot race the foreign-schema mutation.
434
+ const secondRefusal = processRefusal(checkProcesses());
435
+ if (secondRefusal) return { ok: false, error: secondRefusal };
436
+
437
+ effectiveMode = typeof requestedMode === "function" ? requestedMode() : requestedMode;
438
+ const mutation = mutateOwnedTrigger(databasePath, effectiveMode);
439
+ if (!mutation.ok) return mutation;
440
+
441
+ // Desired state belongs to the same logical transition as the trigger.
442
+ // Keep L held through this write so another OpenCodex process cannot
443
+ // interleave a different mode between the DB commit and config commit.
444
+ try {
445
+ writeDesired(effectiveMode);
446
+ } catch {
447
+ restoreOwnedTriggers(databasePath, mutation.previousTriggers);
448
+ return { ok: false, error: "config_write_failed" as const };
449
+ }
450
+ return { ok: true };
451
+ });
452
+ } catch {
453
+ return { ok: false, error: "database_error" };
454
+ }
455
+ if (locked.kind === "unavailable") {
456
+ return {
457
+ ok: false,
458
+ error: locked.reason === "busy"
459
+ ? "busy"
460
+ : locked.reason === "unsafe-path" ? "unsafe_path" : "database_error",
461
+ };
462
+ }
463
+ if (!locked.value.ok) return locked.value;
464
+
465
+ // Report the mode that was actually applied under the lock, not the one the
466
+ // caller guessed before acquiring it.
467
+ return { ok: true, status: successfulMutationStatus(codexHome, effectiveMode) };
468
+ }
469
+
470
+ export function protectCodexLogs(
471
+ mode: Exclude<CodexLogGuardMode, "off">,
472
+ deps: CodexLogGuardProtectionDeps = {},
473
+ ): CodexLogGuardMutationResult {
474
+ return performMutation(mode, deps);
475
+ }
476
+
477
+ export function unprotectCodexLogs(
478
+ deps: CodexLogGuardProtectionDeps = {},
479
+ ): CodexLogGuardMutationResult {
480
+ return performMutation("off", deps);
481
+ }
482
+
483
+ export function repairCodexLogGuardProtection(
484
+ deps: CodexLogGuardProtectionDeps = {},
485
+ ): CodexLogGuardMutationResult {
486
+ // Resolve the desired mode under the lock (see performMutation): a Disable
487
+ // that lands between the read and the lock must win, not be silently undone.
488
+ return performMutation(() => (deps.readDesiredMode ?? readCodexLogGuardMode)(), deps);
489
+ }
@@ -0,0 +1,9 @@
1
+ /** Classify SQLite lock contention consistently across Log Guard mutation paths. */
2
+ export function isSqliteBusy(error: unknown): boolean {
3
+ const code = error && typeof error === "object" && "code" in error
4
+ ? String((error as { code?: unknown }).code)
5
+ : "";
6
+ const message = error instanceof Error ? error.message : String(error);
7
+ return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED"
8
+ || /database (?:is|table is) locked/i.test(message);
9
+ }
@@ -108,6 +108,11 @@ export function resolveCodexStateDbPath(deps: CodexSqliteHomeDeps = {}): string
108
108
  return join(resolveCodexSqliteHome(deps), "state_5.sqlite");
109
109
  }
110
110
 
111
+ /** Active Codex diagnostic-log database, derived from the call-time SQLite root. */
112
+ export function resolveCodexLogsDbPath(deps: CodexSqliteHomeDeps = {}): string {
113
+ return join(resolveCodexSqliteHome(deps), "logs_2.sqlite");
114
+ }
115
+
111
116
  export function tomlString(value: string): string {
112
117
  return JSON.stringify(value);
113
118
  }
@@ -59,7 +59,7 @@ function readMarketplaceTable(configText: string, name: string): Record<string,
59
59
  for (let i = start; i < lines.length; i++) {
60
60
  const line = lines[i] ?? "";
61
61
  if (/^\s*\[/.test(line)) break; // next table starts; stop
62
- const m = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*("(?:\\.|[^"])*"|'[^']*'|[^#]+?)\s*(?:#.*)?$/);
62
+ const m = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*("(?:\\.|[^"\\])*"|'[^']*'|(?!["'])[^\s#]+)\s*(?:#.*)?$/);
63
63
  if (!m) continue;
64
64
  table[m[1]] = unquoteTomlValue(m[2].trim());
65
65
  }
@@ -70,7 +70,7 @@ export function parseTomlDocument(content: string): TomlDocument {
70
70
  current = section;
71
71
  continue;
72
72
  }
73
- const kv = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*("(?:\\.|[^"])*"|'[^']*'|[^\s#]+)\s*(?:#.*)?$/);
73
+ const kv = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*("(?:\\.|[^"\\])*"|'[^']*'|[^\s#]+)\s*(?:#.*)?$/);
74
74
  if (kv) current[kv[1]!] = parseTomlString(kv[2]!);
75
75
  }
76
76
 
@@ -422,4 +422,4 @@ export function printProjectCodexConfigWarnings(
422
422
  }
423
423
  }
424
424
  return warnings;
425
- }
425
+ }