@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.
- package/AGENTS_INSTALL.md +32 -0
- package/README.md +1 -1
- package/gui/dist/assets/{index-DSK3S5HY.js → index-ClEcVlFO.js} +43 -17
- package/gui/dist/assets/index-DQsMZzI5.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +2 -28
- package/src/adapters/google.ts +31 -3
- package/src/adapters/openai-chat.ts +25 -8
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/bridge.ts +15 -2
- package/src/claude/agents-inject.ts +2 -2
- package/src/claude/gateway-cache.ts +41 -4
- package/src/cli/claude.ts +1 -1
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/dispatch.ts +7 -1
- package/src/cli/help.ts +1 -1
- package/src/cli/models.ts +16 -6
- package/src/cli/observe.ts +38 -2
- package/src/cli/registry.ts +2 -1
- package/src/cli/v2.ts +34 -1
- package/src/codex/app-server-processes.ts +46 -26
- package/src/codex/catalog/effort.ts +49 -1
- package/src/codex/catalog/parsing.ts +64 -4
- package/src/codex/catalog/provider-fetch.ts +12 -0
- package/src/codex/catalog/sync.ts +14 -1
- package/src/codex/convergence.ts +2 -0
- package/src/codex/inject.ts +3 -3
- package/src/codex/log-guard/inspect.ts +506 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +39 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/paths.ts +5 -0
- package/src/codex/plugins-doctor.ts +1 -1
- package/src/codex/project-config-warnings.ts +2 -2
- package/src/generated/compatibility-version.json +93 -45
- package/src/images/loop.ts +15 -5
- package/src/providers/antigravity-models.ts +11 -1
- package/src/providers/model-discovery.ts +94 -6
- package/src/providers/quota.ts +159 -0
- package/src/providers/registry.ts +20 -1
- package/src/providers/slug-codec.ts +29 -0
- package/src/responses/custom-tool-compat.ts +4 -1
- package/src/responses/parser.ts +7 -1
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/schema.ts +6 -0
- package/src/router.ts +12 -4
- package/src/routing/capability.ts +32 -17
- package/src/server/auth-cors.ts +42 -6
- package/src/server/index.ts +1 -0
- package/src/server/management/agent-settings-routes.ts +20 -2
- package/src/server/management/context.ts +15 -0
- package/src/server/management/model-routes.ts +12 -3
- package/src/server/management/storage-log-guard-routes.ts +186 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/responses/core.ts +13 -0
- package/src/server/system-env.ts +1 -1
- package/src/types.ts +24 -2
- package/src/web-search/loop.ts +21 -5
- 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
|
+
}
|
package/src/codex/paths.ts
CHANGED
|
@@ -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*("(?:\\.|[^"])*"|'[^']*'|[
|
|
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
|
+
}
|