From 247dc38d7833e00cea7f94d43605277f1de94a2d Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 12:23:45 +0900 Subject: [PATCH 1/2] refactor(log-guard): isolate the canonical logs schema check (split S12 L1/3) --- src/codex/log-guard/inspect-schema.ts | 137 ++++++++++++++++++++++++++ src/codex/log-guard/inspect.ts | 136 +------------------------ 2 files changed, 139 insertions(+), 134 deletions(-) create mode 100644 src/codex/log-guard/inspect-schema.ts diff --git a/src/codex/log-guard/inspect-schema.ts b/src/codex/log-guard/inspect-schema.ts new file mode 100644 index 0000000000..e4c6576a04 --- /dev/null +++ b/src/codex/log-guard/inspect-schema.ts @@ -0,0 +1,137 @@ +import type { Database } from "bun:sqlite"; + +interface CurrentLogColumn { + name: string; + type: string; + notnull: number; + defaultValue: string | null; + pk: number; +} + +// Pinned to Codex logs migration 0002. Keep this schema private: inspection reports +// compatibility, not column names, so sensitive payload-bearing fields never leak through +// the management API. Any additive/rebuilt future schema is monitor-only until reviewed. +const CURRENT_LOG_SCHEMA: readonly CurrentLogColumn[] = [ + { name: "id", type: "INTEGER", notnull: 0, defaultValue: null, pk: 1 }, + { name: "ts", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 }, + { name: "ts_nanos", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 }, + { name: "level", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }, + { name: "target", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }, + { name: "feedback_log_body", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, + { name: "module_path", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, + { name: "file", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, + { name: "line", type: "INTEGER", notnull: 0, defaultValue: null, pk: 0 }, + { name: "thread_id", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, + { name: "process_uuid", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, + { name: "estimated_bytes", type: "INTEGER", notnull: 1, defaultValue: "0", pk: 0 }, +] as const; + +const CURRENT_LOG_TABLE_SQL = `CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ts_nanos INTEGER NOT NULL, + level TEXT NOT NULL, + target TEXT NOT NULL, + feedback_log_body TEXT, + module_path TEXT, + file TEXT, + line INTEGER, + thread_id TEXT, + process_uuid TEXT, + estimated_bytes INTEGER NOT NULL DEFAULT 0 +)`; + +const CURRENT_LOG_INDEX_SQL = { + idx_logs_ts: "CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC)", + idx_logs_thread_id: "CREATE INDEX idx_logs_thread_id ON logs(thread_id)", + idx_logs_thread_id_ts: "CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC)", + idx_logs_process_uuid_threadless_ts: `CREATE INDEX idx_logs_process_uuid_threadless_ts + ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) + WHERE thread_id IS NULL`, +} as const; + + +export interface ColumnRow { + cid: number; + name: string; + type: string; + notnull: number; + dflt_value: string | null; + pk: number; +} +interface SchemaObjectRow { name: string; type: string; sql: string | null } + +function normalizeDeclaredType(type: string): string { + return String(type ?? "").trim().toUpperCase(); +} + +function normalizeDefault(value: string | null): string | null { + return value === null ? null : String(value).trim(); +} + +function normalizeSchemaSql(sql: string | null | undefined): string { + return (sql ?? "").trim().replace(/;\s*$/, "").replace(/\s+/g, " "); +} + +function sameColumns(columns: ColumnRow[]): boolean { + if (columns.length !== CURRENT_LOG_SCHEMA.length) return false; + return columns.every((column, index) => { + const expected = CURRENT_LOG_SCHEMA[index]; + return column.cid === index + && column.name === expected.name + && normalizeDeclaredType(column.type) === expected.type + && Number(column.notnull) === expected.notnull + && normalizeDefault(column.dflt_value) === expected.defaultValue + && Number(column.pk) === expected.pk; + }); +} + +/** + * The authoritative compatibility predicate: exact table SQL, exact column + * metadata, and every canonical index. + * + * Exported because the mutation paths must apply the SAME test inside their + * write transaction. They used to check column NAMES only, which is strictly + * weaker than what the inspector reports, so a schema change landing between + * the outer inspection and the locked write let Protect install a row-dropping + * trigger and let Reclaim vacuum pages on a database the inspector classifies + * as monitor-only. The lock serializes OpenCodex against itself; it does not + * stop Codex or another SQLite writer, so that TOCTOU window is real. + */ +export function hasCurrentLogsSchema(db: Database): boolean { + const columns = db.query("PRAGMA table_info(logs)").all(); + return hasCurrentLogsTable(db, columns); +} + +export function hasCurrentLogsTable(db: Database, columns: ColumnRow[]): boolean { + const table = db.query( + "SELECT name, type, sql FROM sqlite_schema WHERE name = 'logs' LIMIT 1", + ).get(); + if (table?.type !== "table" + || !sameColumns(columns) + || normalizeSchemaSql(table.sql) !== normalizeSchemaSql(CURRENT_LOG_TABLE_SQL)) { + return false; + } + + const indexes = db.query(` + SELECT name, type, sql FROM sqlite_schema + WHERE name IN ( + 'idx_logs_ts', + 'idx_logs_thread_id', + 'idx_logs_thread_id_ts', + 'idx_logs_process_uuid_threadless_ts' + ) + `).all(); + const byName = new Map(indexes.map(row => [row.name, row])); + for (const [name, expectedSql] of Object.entries(CURRENT_LOG_INDEX_SQL)) { + const row = byName.get(name); + if (row?.type !== "index" || normalizeSchemaSql(row.sql) !== normalizeSchemaSql(expectedSql)) { + return false; + } + } + + // Extra indexes and triggers do not redefine the table contract. In particular, + // Protect intentionally installs OpenCodex-owned triggers and unrelated user triggers + // are supported, so compatibility is based on the canonical table plus required indexes. + return true; +} diff --git a/src/codex/log-guard/inspect.ts b/src/codex/log-guard/inspect.ts index eca4402e25..d3d7d23dbe 100644 --- a/src/codex/log-guard/inspect.ts +++ b/src/codex/log-guard/inspect.ts @@ -8,6 +8,8 @@ import { resolveCodexSqliteHome, type CodexSqliteHomeDeps, } from "../paths"; +export { hasCurrentLogsSchema } from "./inspect-schema"; +import { hasCurrentLogsTable, type ColumnRow } from "./inspect-schema"; const IMMUTABLE_READONLY_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI; const KNOWN_LOG_LEVELS = new Set(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]); @@ -15,56 +17,6 @@ const KNOWN_LOG_LEVELS = new Set(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]); // alone; skipping all row aggregates above 64 MiB reduced /api/storage to 628ms. const MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES = 64 * 1024 * 1024; -interface CurrentLogColumn { - name: string; - type: string; - notnull: number; - defaultValue: string | null; - pk: number; -} - -// Pinned to Codex logs migration 0002. Keep this schema private: inspection reports -// compatibility, not column names, so sensitive payload-bearing fields never leak through -// the management API. Any additive/rebuilt future schema is monitor-only until reviewed. -const CURRENT_LOG_SCHEMA: readonly CurrentLogColumn[] = [ - { name: "id", type: "INTEGER", notnull: 0, defaultValue: null, pk: 1 }, - { name: "ts", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 }, - { name: "ts_nanos", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 }, - { name: "level", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }, - { name: "target", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }, - { name: "feedback_log_body", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, - { name: "module_path", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, - { name: "file", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, - { name: "line", type: "INTEGER", notnull: 0, defaultValue: null, pk: 0 }, - { name: "thread_id", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, - { name: "process_uuid", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 }, - { name: "estimated_bytes", type: "INTEGER", notnull: 1, defaultValue: "0", pk: 0 }, -] as const; - -const CURRENT_LOG_TABLE_SQL = `CREATE TABLE logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts INTEGER NOT NULL, - ts_nanos INTEGER NOT NULL, - level TEXT NOT NULL, - target TEXT NOT NULL, - feedback_log_body TEXT, - module_path TEXT, - file TEXT, - line INTEGER, - thread_id TEXT, - process_uuid TEXT, - estimated_bytes INTEGER NOT NULL DEFAULT 0 -)`; - -const CURRENT_LOG_INDEX_SQL = { - idx_logs_ts: "CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC)", - idx_logs_thread_id: "CREATE INDEX idx_logs_thread_id ON logs(thread_id)", - idx_logs_thread_id_ts: "CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC)", - idx_logs_process_uuid_threadless_ts: `CREATE INDEX idx_logs_process_uuid_threadless_ts - ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) - WHERE thread_id IS NULL`, -} as const; - export type CodexLogGuardCapabilityReason = | "database_missing" | "database_unreadable" @@ -124,15 +76,6 @@ export interface CodexLogGuardInspection { }; } -interface ColumnRow { - cid: number; - name: string; - type: string; - notnull: number; - dflt_value: string | null; - pk: number; -} -interface SchemaObjectRow { name: string; type: string; sql: string | null } interface CountRow { n: number } interface LevelRow { level: string; rows: number } interface TargetCountRow { rows: number } @@ -243,81 +186,6 @@ function unavailableInspection(): CodexLogGuardInspection { }; } -function normalizeDeclaredType(type: string): string { - return String(type ?? "").trim().toUpperCase(); -} - -function normalizeDefault(value: string | null): string | null { - return value === null ? null : String(value).trim(); -} - -function normalizeSchemaSql(sql: string | null | undefined): string { - return (sql ?? "").trim().replace(/;\s*$/, "").replace(/\s+/g, " "); -} - -function sameColumns(columns: ColumnRow[]): boolean { - if (columns.length !== CURRENT_LOG_SCHEMA.length) return false; - return columns.every((column, index) => { - const expected = CURRENT_LOG_SCHEMA[index]; - return column.cid === index - && column.name === expected.name - && normalizeDeclaredType(column.type) === expected.type - && Number(column.notnull) === expected.notnull - && normalizeDefault(column.dflt_value) === expected.defaultValue - && Number(column.pk) === expected.pk; - }); -} - -/** - * The authoritative compatibility predicate: exact table SQL, exact column - * metadata, and every canonical index. - * - * Exported because the mutation paths must apply the SAME test inside their - * write transaction. They used to check column NAMES only, which is strictly - * weaker than what the inspector reports, so a schema change landing between - * the outer inspection and the locked write let Protect install a row-dropping - * trigger and let Reclaim vacuum pages on a database the inspector classifies - * as monitor-only. The lock serializes OpenCodex against itself; it does not - * stop Codex or another SQLite writer, so that TOCTOU window is real. - */ -export function hasCurrentLogsSchema(db: Database): boolean { - const columns = db.query("PRAGMA table_info(logs)").all(); - return hasCurrentLogsTable(db, columns); -} - -function hasCurrentLogsTable(db: Database, columns: ColumnRow[]): boolean { - const table = db.query( - "SELECT name, type, sql FROM sqlite_schema WHERE name = 'logs' LIMIT 1", - ).get(); - if (table?.type !== "table" - || !sameColumns(columns) - || normalizeSchemaSql(table.sql) !== normalizeSchemaSql(CURRENT_LOG_TABLE_SQL)) { - return false; - } - - const indexes = db.query(` - SELECT name, type, sql FROM sqlite_schema - WHERE name IN ( - 'idx_logs_ts', - 'idx_logs_thread_id', - 'idx_logs_thread_id_ts', - 'idx_logs_process_uuid_threadless_ts' - ) - `).all(); - const byName = new Map(indexes.map(row => [row.name, row])); - for (const [name, expectedSql] of Object.entries(CURRENT_LOG_INDEX_SQL)) { - const row = byName.get(name); - if (row?.type !== "index" || normalizeSchemaSql(row.sql) !== normalizeSchemaSql(expectedSql)) { - return false; - } - } - - // Extra indexes and triggers do not redefine the table contract. In particular, - // Protect intentionally installs OpenCodex-owned triggers and unrelated user triggers - // are supported, so compatibility is based on the canonical table plus required indexes. - return true; -} - function pragmaNumber(db: Database, pragma: "page_size" | "page_count" | "freelist_count"): number { const row = db.query, []>(`PRAGMA ${pragma}`).get(); return Number(row?.[pragma] ?? 0); From 5c1a398da78975312c183c1c2b6e0ff8241ac02c Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 12:24:07 +0900 Subject: [PATCH 2/2] test(log-guard): cover the inspect-schema seam (split S12 L1/3) --- .../codex-integration/codex-log-guard-inspect.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/codex-integration/codex-log-guard-inspect.test.ts b/tests/codex-integration/codex-log-guard-inspect.test.ts index 063a64a605..b6a00069cd 100644 --- a/tests/codex-integration/codex-log-guard-inspect.test.ts +++ b/tests/codex-integration/codex-log-guard-inspect.test.ts @@ -493,3 +493,14 @@ describe("Codex Log Guard inspection", () => { expect(after.schema.state).not.toBe("compatible"); }); }); + +test("inspect-schema preserves the public predicate identity without a back-edge", async () => { + const { hasCurrentLogsSchema } = await import("../../src/codex/log-guard/inspect"); + const { hasCurrentLogsSchema: schemaPredicate } = await import("../../src/codex/log-guard/inspect-schema"); + const { readFileSync } = await import("node:fs"); + const { repoPath } = await import("../helpers/repo-root"); + + expect(hasCurrentLogsSchema).toBe(schemaPredicate); + const source = readFileSync(repoPath("src/codex/log-guard/inspect-schema.ts"), "utf8"); + expect(source.split("\n").some(line => /from\s+["']\.\/inspect["']/.test(line))).toBe(false); +});