@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,403 @@
|
|
|
1
|
+
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { Database, constants as sqliteConstants } from "bun:sqlite";
|
|
3
|
+
|
|
4
|
+
import { getCodexHome, resolveCodexLogsDbPath } from "../paths";
|
|
5
|
+
import { samePathIdentity } from "../user-identity";
|
|
6
|
+
import { hasCurrentLogsSchema, inspectCodexLogs } from "./inspect";
|
|
7
|
+
import { withCodexLogGuardLock, type CodexLogGuardLockOutcome } from "./lock";
|
|
8
|
+
import { sameLogGuardPathIdentity } from "./path-safety";
|
|
9
|
+
import { isSqliteBusy } from "./sqlite-errors";
|
|
10
|
+
import { listRunningCodexProcesses, type CodexWriterProcessCheck } from "./processes";
|
|
11
|
+
|
|
12
|
+
const CURRENT_LOG_COLUMNS = [
|
|
13
|
+
"id",
|
|
14
|
+
"ts",
|
|
15
|
+
"ts_nanos",
|
|
16
|
+
"level",
|
|
17
|
+
"target",
|
|
18
|
+
"feedback_log_body",
|
|
19
|
+
"module_path",
|
|
20
|
+
"file",
|
|
21
|
+
"line",
|
|
22
|
+
"thread_id",
|
|
23
|
+
"process_uuid",
|
|
24
|
+
"estimated_bytes",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Budgets are expressed in BYTES and converted with the database's real page
|
|
29
|
+
* size, because that is what the guide promises: ~8 MiB per batch and ~256 MiB
|
|
30
|
+
* per run. Fixed page counts silently meant something different on every page
|
|
31
|
+
* size — at the 4 KiB pages the fixtures use, 512/8192 pages is 2 MiB/32 MiB,
|
|
32
|
+
* a quarter of the documented budget.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_BATCH_BYTES = 8 * 1024 * 1024;
|
|
35
|
+
const DEFAULT_MAX_BYTES_PER_RUN = 256 * 1024 * 1024;
|
|
36
|
+
const MAX_ITERATIONS = 64;
|
|
37
|
+
|
|
38
|
+
/** Convert a byte budget to whole pages, never returning zero pages. */
|
|
39
|
+
function pagesForBytes(bytes: number, pageSize: number): number {
|
|
40
|
+
if (!Number.isFinite(pageSize) || pageSize <= 0) return 1;
|
|
41
|
+
return Math.max(1, Math.floor(bytes / pageSize));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type CompactStopReason = "complete" | "page_budget" | "no_progress" | "busy";
|
|
45
|
+
|
|
46
|
+
export interface CodexLogGuardCompactionMeasure {
|
|
47
|
+
databaseBytes: number;
|
|
48
|
+
/** On-disk WAL sidecar size at measurement time; FULL checkpoint does not imply shrinkage. */
|
|
49
|
+
walBytes: number;
|
|
50
|
+
pageCount: number;
|
|
51
|
+
freelistPages: number;
|
|
52
|
+
reclaimableBytes: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CodexLogGuardCompactionReport {
|
|
56
|
+
pageSize: number;
|
|
57
|
+
before: CodexLogGuardCompactionMeasure;
|
|
58
|
+
after: CodexLogGuardCompactionMeasure;
|
|
59
|
+
pagesReclaimed: number;
|
|
60
|
+
/**
|
|
61
|
+
* Logical space returned to the free list, in bytes (`pagesReclaimed * pageSize`).
|
|
62
|
+
* Distinct from `physicalDatabaseBytesReclaimed`: an incremental vacuum can
|
|
63
|
+
* reclaim pages without the file shrinking, so a run that reports logical
|
|
64
|
+
* progress and zero physical shrinkage is normal rather than a failure. The
|
|
65
|
+
* guide documented this field before it existed.
|
|
66
|
+
*/
|
|
67
|
+
logicalBytesReclaimed: number;
|
|
68
|
+
physicalDatabaseBytesReclaimed: number;
|
|
69
|
+
iterations: number;
|
|
70
|
+
complete: boolean;
|
|
71
|
+
stopReason: CompactStopReason;
|
|
72
|
+
integrity: { before: "ok"; after: "ok" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type CodexLogGuardCompactionError =
|
|
76
|
+
| "unsupported_schema"
|
|
77
|
+
| "codex_running"
|
|
78
|
+
| "process_enumeration_failed"
|
|
79
|
+
| "unsafe_path"
|
|
80
|
+
| "busy"
|
|
81
|
+
| "database_error"
|
|
82
|
+
| "auto_vacuum_not_incremental"
|
|
83
|
+
| "integrity_check_failed";
|
|
84
|
+
|
|
85
|
+
export type CodexLogGuardCompactionResult =
|
|
86
|
+
| { ok: true; report: CodexLogGuardCompactionReport }
|
|
87
|
+
| {
|
|
88
|
+
ok: false;
|
|
89
|
+
error: Exclude<CodexLogGuardCompactionError, "integrity_check_failed">;
|
|
90
|
+
}
|
|
91
|
+
| { ok: false; error: "integrity_check_failed"; phase: "before" | "after" };
|
|
92
|
+
|
|
93
|
+
export interface CodexLogGuardMaintenanceDeps {
|
|
94
|
+
codexHome?: string;
|
|
95
|
+
processCheck?: () => CodexWriterProcessCheck;
|
|
96
|
+
withLock?: <T>(
|
|
97
|
+
canonicalCodexHome: string,
|
|
98
|
+
canonicalLogsDbPath: string,
|
|
99
|
+
work: () => T,
|
|
100
|
+
) => CodexLogGuardLockOutcome<T>;
|
|
101
|
+
quickCheck?: (db: Database) => string[];
|
|
102
|
+
openDatabase?: (databasePath: string, flags: number) => Database;
|
|
103
|
+
batchPages?: number;
|
|
104
|
+
maxPagesPerRun?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface ColumnRow { name: string }
|
|
108
|
+
interface CheckpointRow {
|
|
109
|
+
busy?: number;
|
|
110
|
+
log?: number;
|
|
111
|
+
checkpointed?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface DatabaseFileIdentity {
|
|
115
|
+
dev: number;
|
|
116
|
+
ino: number;
|
|
117
|
+
realPath: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function databasePathIdentity(databasePath: string): DatabaseFileIdentity | null {
|
|
121
|
+
try {
|
|
122
|
+
const stat = lstatSync(databasePath);
|
|
123
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
|
124
|
+
const realPath = realpathSync.native(databasePath);
|
|
125
|
+
if (!sameLogGuardPathIdentity(realPath, databasePath)) return null;
|
|
126
|
+
return { dev: stat.dev, ino: stat.ino, realPath };
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function databasePathIsSafe(databasePath: string): boolean {
|
|
133
|
+
return databasePathIdentity(databasePath) !== null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function databasePathStillMatches(
|
|
137
|
+
databasePath: string,
|
|
138
|
+
before: DatabaseFileIdentity,
|
|
139
|
+
): boolean {
|
|
140
|
+
const after = databasePathIdentity(databasePath);
|
|
141
|
+
return after !== null
|
|
142
|
+
&& after.dev === before.dev
|
|
143
|
+
&& after.ino === before.ino
|
|
144
|
+
&& samePathIdentity(after.realPath, before.realPath);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function exactCurrentSchema(db: Database): boolean {
|
|
148
|
+
// Same reasoning as protection.ts: the pre-mutation recheck must match the
|
|
149
|
+
// inspector's compatibility contract exactly, or Reclaim can vacuum a
|
|
150
|
+
// database the inspector classifies as monitor-only.
|
|
151
|
+
return hasCurrentLogsSchema(db);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function pragmaNumber(db: Database, sql: string): number {
|
|
155
|
+
const row = db.query<Record<string, unknown>, []>(sql).get();
|
|
156
|
+
if (!row) throw new Error(`missing pragma result for ${sql}`);
|
|
157
|
+
const value = Number(Object.values(row)[0]);
|
|
158
|
+
if (!Number.isFinite(value)) throw new Error(`invalid pragma result for ${sql}`);
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function defaultQuickCheck(db: Database): string[] {
|
|
163
|
+
return db.query<Record<string, unknown>, []>("PRAGMA quick_check").all().map(row => {
|
|
164
|
+
const value = Object.values(row)[0];
|
|
165
|
+
return value === undefined ? "" : String(value);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function quickCheckIsOk(rows: string[]): boolean {
|
|
170
|
+
return rows.length === 1 && rows[0]?.trim().toLowerCase() === "ok";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function processRefusal(
|
|
174
|
+
check: CodexWriterProcessCheck,
|
|
175
|
+
): "process_enumeration_failed" | "codex_running" | null {
|
|
176
|
+
if (check.state === "unknown") return "process_enumeration_failed";
|
|
177
|
+
if (check.processes.length > 0) return "codex_running";
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function checkpointFull(db: Database): "ok" | "busy" {
|
|
182
|
+
const row = db.query<CheckpointRow, []>("PRAGMA wal_checkpoint(FULL)").get();
|
|
183
|
+
if (!row) throw new Error("missing wal_checkpoint result");
|
|
184
|
+
const values = Object.values(row).map(Number);
|
|
185
|
+
const busy = Number(row.busy ?? values[0] ?? 0);
|
|
186
|
+
const log = Number(row.log ?? values[1] ?? -1);
|
|
187
|
+
const checkpointed = Number(row.checkpointed ?? values[2] ?? -1);
|
|
188
|
+
if (busy !== 0) return "busy";
|
|
189
|
+
// SQLite returns -1/-1 when the database is not in WAL mode or there are no
|
|
190
|
+
// WAL frames to report. Otherwise FULL must have copied every frame.
|
|
191
|
+
if (log >= 0 && checkpointed >= 0 && checkpointed < log) return "busy";
|
|
192
|
+
return "ok";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function measure(databasePath: string, db: Database, pageSize: number): CodexLogGuardCompactionMeasure {
|
|
196
|
+
const databaseBytes = (() => {
|
|
197
|
+
try {
|
|
198
|
+
const stat = statSync(databasePath);
|
|
199
|
+
return stat.isFile() ? stat.size : 0;
|
|
200
|
+
} catch {
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
})();
|
|
204
|
+
const walBytes = (() => {
|
|
205
|
+
try {
|
|
206
|
+
const stat = statSync(`${databasePath}-wal`);
|
|
207
|
+
return stat.isFile() ? stat.size : 0;
|
|
208
|
+
} catch {
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
})();
|
|
212
|
+
const pageCount = pragmaNumber(db, "PRAGMA page_count");
|
|
213
|
+
const freelistPages = pragmaNumber(db, "PRAGMA freelist_count");
|
|
214
|
+
return {
|
|
215
|
+
databaseBytes,
|
|
216
|
+
walBytes,
|
|
217
|
+
pageCount,
|
|
218
|
+
freelistPages,
|
|
219
|
+
reclaimableBytes: pageSize * freelistPages,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function runCompaction(
|
|
224
|
+
databasePath: string,
|
|
225
|
+
deps: CodexLogGuardMaintenanceDeps,
|
|
226
|
+
): CodexLogGuardCompactionResult {
|
|
227
|
+
let db: Database | undefined;
|
|
228
|
+
let probeOpen = false;
|
|
229
|
+
let reportBusyPartial: (() => CodexLogGuardCompactionResult) | undefined;
|
|
230
|
+
try {
|
|
231
|
+
const beforeOpenIdentity = databasePathIdentity(databasePath);
|
|
232
|
+
if (!beforeOpenIdentity) return { ok: false, error: "unsafe_path" };
|
|
233
|
+
const openDatabase = deps.openDatabase
|
|
234
|
+
?? ((path: string, flags: number) => new Database(path, flags));
|
|
235
|
+
db = openDatabase(databasePath, sqliteConstants.SQLITE_OPEN_READWRITE);
|
|
236
|
+
// The path is user-writable foreign state. Re-check its regular-file,
|
|
237
|
+
// canonical-path and st_dev/st_ino identity immediately after SQLite opens
|
|
238
|
+
// it, before issuing any pragma or write-capable statement.
|
|
239
|
+
if (!databasePathStillMatches(databasePath, beforeOpenIdentity)) {
|
|
240
|
+
return { ok: false, error: "unsafe_path" };
|
|
241
|
+
}
|
|
242
|
+
db.exec("PRAGMA busy_timeout = 0");
|
|
243
|
+
|
|
244
|
+
if (!exactCurrentSchema(db)) return { ok: false, error: "unsupported_schema" };
|
|
245
|
+
if (pragmaNumber(db, "PRAGMA auto_vacuum") !== 2) {
|
|
246
|
+
return { ok: false, error: "auto_vacuum_not_incremental" };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const quickCheck = deps.quickCheck ?? defaultQuickCheck;
|
|
250
|
+
if (!quickCheckIsOk(quickCheck(db))) {
|
|
251
|
+
return { ok: false, error: "integrity_check_failed", phase: "before" };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Confirm no SQLite writer can acquire the file before the first checkpoint.
|
|
255
|
+
// BEGIN IMMEDIATE is intentionally released before PRAGMA wal_checkpoint,
|
|
256
|
+
// which cannot run while this same connection holds a write transaction.
|
|
257
|
+
db.exec("BEGIN IMMEDIATE");
|
|
258
|
+
probeOpen = true;
|
|
259
|
+
db.exec("ROLLBACK");
|
|
260
|
+
probeOpen = false;
|
|
261
|
+
|
|
262
|
+
if (checkpointFull(db) === "busy") return { ok: false, error: "busy" };
|
|
263
|
+
|
|
264
|
+
const pageSize = pragmaNumber(db, "PRAGMA page_size");
|
|
265
|
+
const before = measure(databasePath, db, pageSize);
|
|
266
|
+
// Derived from the byte budgets AFTER reading the real page size, so the
|
|
267
|
+
// documented ~8 MiB batch / ~256 MiB run hold at any page size. Explicit
|
|
268
|
+
// page-count overrides still win, which is what the tests use.
|
|
269
|
+
const batchPages = Math.max(1, Math.floor(deps.batchPages ?? pagesForBytes(DEFAULT_BATCH_BYTES, pageSize)));
|
|
270
|
+
const maxPages = Math.max(batchPages, Math.floor(deps.maxPagesPerRun ?? pagesForBytes(DEFAULT_MAX_BYTES_PER_RUN, pageSize)));
|
|
271
|
+
let previousFreelist = before.freelistPages;
|
|
272
|
+
let pagesReclaimed = 0;
|
|
273
|
+
let iterations = 0;
|
|
274
|
+
let stopReason: CompactStopReason = previousFreelist === 0 ? "complete" : "page_budget";
|
|
275
|
+
|
|
276
|
+
const finish = (reason: CompactStopReason): CodexLogGuardCompactionResult => {
|
|
277
|
+
const after = measure(databasePath, db!, pageSize);
|
|
278
|
+
if (!quickCheckIsOk(quickCheck(db!))) {
|
|
279
|
+
return { ok: false, error: "integrity_check_failed", phase: "after" };
|
|
280
|
+
}
|
|
281
|
+
const complete = after.freelistPages === 0;
|
|
282
|
+
// If SQLITE_BUSY is thrown after an incremental_vacuum commit but before the
|
|
283
|
+
// loop can sample freelist_count, the before/after measurements still capture
|
|
284
|
+
// that committed logical reclamation. Never under-report already-landed work.
|
|
285
|
+
const observedPagesReclaimed = Math.max(
|
|
286
|
+
pagesReclaimed,
|
|
287
|
+
Math.max(0, before.freelistPages - after.freelistPages),
|
|
288
|
+
);
|
|
289
|
+
return {
|
|
290
|
+
ok: true,
|
|
291
|
+
report: {
|
|
292
|
+
pageSize,
|
|
293
|
+
before,
|
|
294
|
+
after,
|
|
295
|
+
pagesReclaimed: observedPagesReclaimed,
|
|
296
|
+
logicalBytesReclaimed: observedPagesReclaimed * pageSize,
|
|
297
|
+
physicalDatabaseBytesReclaimed: Math.max(0, before.databaseBytes - after.databaseBytes),
|
|
298
|
+
iterations,
|
|
299
|
+
complete,
|
|
300
|
+
stopReason: reason === "busy" ? "busy" : complete ? "complete" : reason,
|
|
301
|
+
integrity: { before: "ok", after: "ok" },
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
};
|
|
305
|
+
reportBusyPartial = () => iterations > 0 ? finish("busy") : { ok: false, error: "busy" };
|
|
306
|
+
|
|
307
|
+
while (previousFreelist > 0 && pagesReclaimed < maxPages && iterations < MAX_ITERATIONS) {
|
|
308
|
+
const pageBudget = Math.min(batchPages, maxPages - pagesReclaimed, previousFreelist);
|
|
309
|
+
if (pageBudget <= 0) {
|
|
310
|
+
stopReason = "page_budget";
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
const priorFreelist = previousFreelist;
|
|
314
|
+
db.exec(`PRAGMA incremental_vacuum(${pageBudget})`);
|
|
315
|
+
iterations += 1;
|
|
316
|
+
const checkpoint = checkpointFull(db);
|
|
317
|
+
const currentFreelist = pragmaNumber(db, "PRAGMA freelist_count");
|
|
318
|
+
const reclaimed = Math.max(0, priorFreelist - currentFreelist);
|
|
319
|
+
pagesReclaimed += reclaimed;
|
|
320
|
+
previousFreelist = currentFreelist;
|
|
321
|
+
|
|
322
|
+
// incremental_vacuum has already committed by this point. A busy FULL
|
|
323
|
+
// checkpoint is therefore a partial-success stop, not an atomic refusal.
|
|
324
|
+
if (checkpoint === "busy") return finish("busy");
|
|
325
|
+
if (currentFreelist === 0) {
|
|
326
|
+
stopReason = "complete";
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
if (currentFreelist >= priorFreelist) {
|
|
330
|
+
stopReason = "no_progress";
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
stopReason = "page_budget";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (previousFreelist > 0 && iterations >= MAX_ITERATIONS && stopReason !== "no_progress") {
|
|
337
|
+
// MAX_ITERATIONS is a bounded-work limit, not evidence that vacuum stalled.
|
|
338
|
+
stopReason = "page_budget";
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// The preceding incremental-vacuum iterations checkpoint after every batch.
|
|
342
|
+
// One final FULL checkpoint backfills any remaining WAL frames before the
|
|
343
|
+
// final main-database measurement. FULL does not reset or shrink the WAL
|
|
344
|
+
// sidecar, so `after.walBytes` is an observational size, not reclaimed WAL.
|
|
345
|
+
if (checkpointFull(db) === "busy") {
|
|
346
|
+
return iterations > 0 ? finish("busy") : { ok: false, error: "busy" };
|
|
347
|
+
}
|
|
348
|
+
return finish(stopReason);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (probeOpen) {
|
|
351
|
+
try { db?.exec("ROLLBACK"); } catch { /* close releases it */ }
|
|
352
|
+
}
|
|
353
|
+
if (isSqliteBusy(error)) {
|
|
354
|
+
// Mirror the explicit busy exits. Once at least one vacuum batch completed,
|
|
355
|
+
// a later thrown busy is a partial-success stop rather than a pure refusal.
|
|
356
|
+
if (reportBusyPartial) {
|
|
357
|
+
try { return reportBusyPartial(); } catch { /* fall through to refusal */ }
|
|
358
|
+
}
|
|
359
|
+
return { ok: false, error: "busy" };
|
|
360
|
+
}
|
|
361
|
+
return { ok: false, error: "database_error" };
|
|
362
|
+
} finally {
|
|
363
|
+
try { db?.close(); } catch { /* maintenance already settled */ }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function compactCodexLogs(
|
|
368
|
+
deps: CodexLogGuardMaintenanceDeps = {},
|
|
369
|
+
): CodexLogGuardCompactionResult {
|
|
370
|
+
const codexHome = deps.codexHome ?? getCodexHome();
|
|
371
|
+
const inspection = inspectCodexLogs({ codexHome });
|
|
372
|
+
const databasePath = resolveCodexLogsDbPath({ codexHome });
|
|
373
|
+
if (inspection.capabilities.reclaim.state !== "supported") {
|
|
374
|
+
return { ok: false, error: "unsupported_schema" };
|
|
375
|
+
}
|
|
376
|
+
if (!databasePathIsSafe(databasePath)) return { ok: false, error: "unsafe_path" };
|
|
377
|
+
|
|
378
|
+
const checkProcesses = deps.processCheck ?? listRunningCodexProcesses;
|
|
379
|
+
const firstRefusal = processRefusal(checkProcesses());
|
|
380
|
+
if (firstRefusal) return { ok: false, error: firstRefusal };
|
|
381
|
+
|
|
382
|
+
const withLock = deps.withLock ?? withCodexLogGuardLock;
|
|
383
|
+
let locked: CodexLogGuardLockOutcome<CodexLogGuardCompactionResult>;
|
|
384
|
+
try {
|
|
385
|
+
locked = withLock(codexHome, databasePath, () => {
|
|
386
|
+
const secondRefusal = processRefusal(checkProcesses());
|
|
387
|
+
if (secondRefusal) return { ok: false as const, error: secondRefusal };
|
|
388
|
+
return runCompaction(databasePath, deps);
|
|
389
|
+
});
|
|
390
|
+
} catch {
|
|
391
|
+
return { ok: false, error: "database_error" };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (locked.kind === "unavailable") {
|
|
395
|
+
return {
|
|
396
|
+
ok: false,
|
|
397
|
+
error: locked.reason === "busy"
|
|
398
|
+
? "busy"
|
|
399
|
+
: locked.reason === "unsafe-path" ? "unsafe_path" : "database_error",
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return locked.value;
|
|
403
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { samePathIdentity } from "../user-identity";
|
|
5
|
+
|
|
6
|
+
const TRUSTED_DARWIN_SYSTEM_ALIASES = [
|
|
7
|
+
{ alias: "/var", canonical: "/private/var" },
|
|
8
|
+
{ alias: "/tmp", canonical: "/private/tmp" },
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
export function normalizeTrustedDarwinSystemAlias(path: string): string {
|
|
12
|
+
const requested = resolve(path);
|
|
13
|
+
if (process.platform !== "darwin") return requested;
|
|
14
|
+
|
|
15
|
+
for (const entry of TRUSTED_DARWIN_SYSTEM_ALIASES) {
|
|
16
|
+
if (requested !== entry.alias && !requested.startsWith(`${entry.alias}${sep}`)) continue;
|
|
17
|
+
|
|
18
|
+
let actualAliasTarget: string;
|
|
19
|
+
try {
|
|
20
|
+
actualAliasTarget = realpathSync.native(entry.alias);
|
|
21
|
+
} catch {
|
|
22
|
+
// If the platform alias is absent or unreadable, keep the strict spelling check.
|
|
23
|
+
return requested;
|
|
24
|
+
}
|
|
25
|
+
if (!samePathIdentity(actualAliasTarget, entry.canonical, "darwin")) return requested;
|
|
26
|
+
return `${entry.canonical}${requested.slice(entry.alias.length)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return requested;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compare a canonical realpath with a requested Log Guard path without treating
|
|
34
|
+
* macOS's OS-owned /var and /tmp aliases as user-controlled redirections.
|
|
35
|
+
* Arbitrary ancestor symlinks remain refused.
|
|
36
|
+
*/
|
|
37
|
+
export function sameLogGuardPathIdentity(realPath: string, requestedPath: string): boolean {
|
|
38
|
+
return samePathIdentity(realPath, normalizeTrustedDarwinSystemAlias(requestedPath));
|
|
39
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { loadConfig, saveConfigPreservingClaudeCode } from "../../config";
|
|
2
|
+
import type { OcxConfig } from "../../types";
|
|
3
|
+
|
|
4
|
+
export type CodexLogGuardMode = "off" | "compat" | "quiet";
|
|
5
|
+
|
|
6
|
+
type ConfigWithLogGuard = OcxConfig & {
|
|
7
|
+
codexLogGuard?: {
|
|
8
|
+
mode?: unknown;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface CodexLogGuardPolicyDeps {
|
|
14
|
+
load?: () => OcxConfig;
|
|
15
|
+
save?: (config: OcxConfig) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readCodexLogGuardMode(deps: CodexLogGuardPolicyDeps = {}): CodexLogGuardMode {
|
|
19
|
+
const config = (deps.load ?? loadConfig)() as ConfigWithLogGuard;
|
|
20
|
+
const mode = config.codexLogGuard?.mode;
|
|
21
|
+
return mode === "compat" || mode === "quiet" ? mode : "off";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Persist user intent separately from Codex's logs database.
|
|
26
|
+
*
|
|
27
|
+
* A Codex migration may rebuild the `logs` table and thereby remove our
|
|
28
|
+
* trigger. Keeping intent in OpenCodex config makes that observable as drift
|
|
29
|
+
* rather than silently treating protection as disabled. `off` is explicit so
|
|
30
|
+
* a stale unknown value cannot reactivate protection later.
|
|
31
|
+
*/
|
|
32
|
+
export function writeCodexLogGuardMode(
|
|
33
|
+
mode: CodexLogGuardMode,
|
|
34
|
+
deps: CodexLogGuardPolicyDeps = {},
|
|
35
|
+
): void {
|
|
36
|
+
const load = deps.load ?? loadConfig;
|
|
37
|
+
const save = deps.save ?? saveConfigPreservingClaudeCode;
|
|
38
|
+
const config = load() as ConfigWithLogGuard;
|
|
39
|
+
const current = config.codexLogGuard && typeof config.codexLogGuard === "object"
|
|
40
|
+
? config.codexLogGuard
|
|
41
|
+
: {};
|
|
42
|
+
config.codexLogGuard = { ...current, mode };
|
|
43
|
+
save(config);
|
|
44
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
listWindowsSnapshots,
|
|
6
|
+
tokenizeCommandLine,
|
|
7
|
+
type ProcessSnapshot,
|
|
8
|
+
} from "../app-server-processes";
|
|
9
|
+
|
|
10
|
+
export interface CodexWriterProcess {
|
|
11
|
+
pid: number;
|
|
12
|
+
commandLine: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type CodexWriterProcessCheck =
|
|
16
|
+
| { state: "ok"; processes: CodexWriterProcess[] }
|
|
17
|
+
| { state: "unknown"; reason: "enumeration_failed" };
|
|
18
|
+
|
|
19
|
+
export interface CodexWriterProcessIo {
|
|
20
|
+
platform?: NodeJS.Platform;
|
|
21
|
+
getuid?: () => number | undefined;
|
|
22
|
+
listSnapshots?: () => ProcessSnapshot[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TARGET_TRIPLE = /^[a-z0-9_]+-[a-z0-9_]+-[a-z0-9_]+(?:-[a-z0-9_]+)?$/i;
|
|
26
|
+
|
|
27
|
+
function basename(token: string): string {
|
|
28
|
+
return token.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isOfficialCodexExecutable(token: string): boolean {
|
|
32
|
+
const base = basename(token);
|
|
33
|
+
if (base === "codex" || base === "codex.exe" || base === "codex.cmd") return true;
|
|
34
|
+
const withoutSuffix = base.replace(/\.(?:exe|cmd)$/i, "");
|
|
35
|
+
if (!withoutSuffix.startsWith("codex-")) return false;
|
|
36
|
+
return TARGET_TRIPLE.test(withoutSuffix.slice("codex-".length));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isCodeModeHostExecutable(token: string): boolean {
|
|
40
|
+
const base = basename(token);
|
|
41
|
+
return base === "codex-code-mode-host" || base === "codex-code-mode-host.exe";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isInterpreterExecutable(token: string): boolean {
|
|
45
|
+
const base = basename(token);
|
|
46
|
+
return base === "node" || base === "node.exe"
|
|
47
|
+
|| base === "bun" || base === "bun.exe"
|
|
48
|
+
|| base === "deno" || base === "deno.exe";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function looksLikePathPrefix(token: string): boolean {
|
|
52
|
+
return token.startsWith("/") || token.startsWith("./") || token.startsWith("../")
|
|
53
|
+
|| token.startsWith("~") || /^[a-z]:[\\/]/i.test(token);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function flattenedPathPrefix(
|
|
57
|
+
commandLine: string,
|
|
58
|
+
predicate: (candidate: string) => boolean,
|
|
59
|
+
): { executable: string; remainder: string } | null {
|
|
60
|
+
const parts = commandLine.trim().split(/\s+/).filter(Boolean);
|
|
61
|
+
for (let end = parts.length; end >= 1; end -= 1) {
|
|
62
|
+
const executable = parts.slice(0, end).join(" ");
|
|
63
|
+
if (!predicate(executable)) continue;
|
|
64
|
+
return { executable, remainder: parts.slice(end).join(" ") };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function interpreterHostWithFlattenedPath(commandLine: string, tokens: readonly string[]): boolean {
|
|
70
|
+
if (tokens.length < 2 || !isInterpreterExecutable(tokens[0]!)) return false;
|
|
71
|
+
const trimmed = commandLine.trim();
|
|
72
|
+
const firstWhitespace = trimmed.search(/\s/);
|
|
73
|
+
if (firstWhitespace < 0) return false;
|
|
74
|
+
const remainder = trimmed.slice(firstWhitespace).trim();
|
|
75
|
+
return flattenedPathPrefix(remainder, isCodeModeHostExecutable) !== null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Match official Codex writer executables at argv0 plus the repository's
|
|
80
|
+
* established interpreter-entrypoint form for code-mode-host.
|
|
81
|
+
*
|
|
82
|
+
* OS process listings can flatten argv into a display string. When an unquoted
|
|
83
|
+
* executable path contains spaces, recover only a leading path-shaped executable
|
|
84
|
+
* prefix; arbitrary later argv tokens still never turn a process into Codex.
|
|
85
|
+
*/
|
|
86
|
+
export function isCodexWriterCommandLine(commandLine: string, executable?: string): boolean {
|
|
87
|
+
if (executable && (isOfficialCodexExecutable(executable) || isCodeModeHostExecutable(executable))) return true;
|
|
88
|
+
const tokens = tokenizeCommandLine(commandLine.trim());
|
|
89
|
+
if (tokens.length === 0) return false;
|
|
90
|
+
if (isOfficialCodexExecutable(tokens[0]!) || isCodeModeHostExecutable(tokens[0]!)) return true;
|
|
91
|
+
return tokens.length > 1
|
|
92
|
+
&& isInterpreterExecutable(tokens[0]!)
|
|
93
|
+
&& isCodeModeHostExecutable(tokens[1]!);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function statusUid(status: string): number | undefined {
|
|
97
|
+
const match = /^Uid:\s+(\d+)/m.exec(status);
|
|
98
|
+
if (!match) return undefined;
|
|
99
|
+
const value = Number(match[1]);
|
|
100
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function listLinuxSnapshots(uid: number | undefined): ProcessSnapshot[] {
|
|
104
|
+
if (!existsSync("/proc")) throw new Error("procfs_unavailable");
|
|
105
|
+
const rows: ProcessSnapshot[] = [];
|
|
106
|
+
for (const entry of readdirSync("/proc")) {
|
|
107
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
108
|
+
const pid = Number(entry);
|
|
109
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) continue;
|
|
110
|
+
try {
|
|
111
|
+
const procUid = statusUid(readFileSync(`/proc/${pid}/status`, "utf8"));
|
|
112
|
+
if (uid !== undefined && procUid !== undefined && procUid !== uid) continue;
|
|
113
|
+
const argv = readFileSync(`/proc/${pid}/cmdline`)
|
|
114
|
+
.toString("utf8")
|
|
115
|
+
.split("\0")
|
|
116
|
+
.filter(Boolean);
|
|
117
|
+
const commandLine = argv.join(" ").trim();
|
|
118
|
+
if (commandLine) rows.push({ pid, commandLine, executable: argv[0], uid: procUid });
|
|
119
|
+
} catch {
|
|
120
|
+
// A process disappearing mid-enumeration is normal. A top-level procfs
|
|
121
|
+
// failure is handled before the loop and fails closed.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return rows;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] {
|
|
128
|
+
const commandOutput = uid !== undefined
|
|
129
|
+
? execFileSync("/bin/ps", ["-u", String(uid), "-o", "pid=,command="], {
|
|
130
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
|
|
131
|
+
})
|
|
132
|
+
: execFileSync("/bin/ps", ["-axo", "pid=,uid=,command="], {
|
|
133
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
|
|
134
|
+
});
|
|
135
|
+
const executableOutput = uid !== undefined
|
|
136
|
+
? execFileSync("/bin/ps", ["-u", String(uid), "-o", "pid=,comm="], {
|
|
137
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
|
|
138
|
+
})
|
|
139
|
+
: execFileSync("/bin/ps", ["-axo", "pid=,comm="], {
|
|
140
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
|
|
141
|
+
});
|
|
142
|
+
const executableByPid = new Map<number, string>();
|
|
143
|
+
for (const raw of executableOutput.split(/\r?\n/)) {
|
|
144
|
+
const match = /^\s*(\d+)\s+(.+)$/.exec(raw);
|
|
145
|
+
if (!match) continue;
|
|
146
|
+
const pid = Number(match[1]);
|
|
147
|
+
const executable = match[2]?.trim() ?? "";
|
|
148
|
+
if (Number.isSafeInteger(pid) && pid > 0 && executable) executableByPid.set(pid, executable);
|
|
149
|
+
}
|
|
150
|
+
const rows: ProcessSnapshot[] = [];
|
|
151
|
+
for (const raw of commandOutput.split(/\r?\n/)) {
|
|
152
|
+
const line = raw.trim();
|
|
153
|
+
if (!line) continue;
|
|
154
|
+
const match = uid !== undefined
|
|
155
|
+
? /^(\d+)\s+(.*)$/.exec(line)
|
|
156
|
+
: /^(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
157
|
+
if (!match) continue;
|
|
158
|
+
const pid = Number(match[1]);
|
|
159
|
+
const commandLine = (uid !== undefined ? match[2] : match[3])?.trim() ?? "";
|
|
160
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || !commandLine) continue;
|
|
161
|
+
rows.push({
|
|
162
|
+
pid, commandLine, executable: executableByPid.get(pid),
|
|
163
|
+
uid: uid ?? (Number.isSafeInteger(Number(match[2])) ? Number(match[2]) : undefined),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function effectiveUid(getuid?: () => number | undefined): number | undefined {
|
|
170
|
+
try {
|
|
171
|
+
return getuid ? getuid() : process.getuid?.();
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function defaultSnapshots(platform: NodeJS.Platform, uid: number | undefined): ProcessSnapshot[] {
|
|
178
|
+
if (platform === "win32") return listWindowsSnapshots();
|
|
179
|
+
if (platform === "darwin") return listDarwinSnapshots(uid);
|
|
180
|
+
if (platform === "linux") return listLinuxSnapshots(uid);
|
|
181
|
+
throw new Error("unsupported_process_enumeration_platform");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function listRunningCodexProcesses(io: CodexWriterProcessIo = {}): CodexWriterProcessCheck {
|
|
185
|
+
const platform = io.platform ?? process.platform;
|
|
186
|
+
let snapshots: ProcessSnapshot[];
|
|
187
|
+
try {
|
|
188
|
+
snapshots = io.listSnapshots?.() ?? defaultSnapshots(platform, effectiveUid(io.getuid));
|
|
189
|
+
} catch {
|
|
190
|
+
return { state: "unknown", reason: "enumeration_failed" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const byPid = new Map<number, CodexWriterProcess>();
|
|
194
|
+
for (const snapshot of snapshots) {
|
|
195
|
+
if (!Number.isSafeInteger(snapshot.pid) || snapshot.pid <= 0) continue;
|
|
196
|
+
if (!isCodexWriterCommandLine(snapshot.commandLine, snapshot.executable)) continue;
|
|
197
|
+
if (!byPid.has(snapshot.pid)) {
|
|
198
|
+
byPid.set(snapshot.pid, { pid: snapshot.pid, commandLine: snapshot.commandLine });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
state: "ok",
|
|
203
|
+
processes: [...byPid.values()].sort((a, b) => a.pid - b.pid),
|
|
204
|
+
};
|
|
205
|
+
}
|