From 00058b50fa897fb5e59eb9f1e2c34af49c00a51f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:21:41 +0200 Subject: [PATCH 1/5] rebase(log-guard): restack protect on latest inspect --- .../content/docs/guides/codex-log-guard.md | 104 ++++- .../storage-workspace/StorageWorkspace.tsx | 189 +++++++- gui/src/i18n/log-guard-labels.ts | 187 +++++++- gui/src/i18n/log-guard-state-labels.ts | 83 ++-- .../storage-log-guard-protection.test.tsx | 103 +++++ src/cli/codex-log-guard-doctor.ts | 44 +- src/cli/observe.ts | 31 +- src/codex/app-server-processes.ts | 72 +-- src/codex/log-guard/lock.ts | 133 ++++++ src/codex/log-guard/path-safety.ts | 39 ++ src/codex/log-guard/policy.ts | 44 ++ src/codex/log-guard/processes.ts | 205 +++++++++ src/codex/log-guard/protection.ts | 431 ++++++++++++++++++ src/codex/log-guard/sqlite-errors.ts | 9 + src/server/management/context.ts | 8 + .../management/storage-log-guard-routes.ts | 92 +++- tests/api-codex-log-guard-protection.test.ts | 178 ++++++++ tests/cli-codex-log-guard-protection.test.ts | 108 +++++ tests/codex-app-server-path-spaces.test.ts | 21 + tests/codex-app-server-processes.test.ts | 2 +- tests/codex-log-guard-coderabbit.test.ts | 197 ++++++++ .../codex-log-guard-doctor-coderabbit.test.ts | 44 ++ .../codex-log-guard-doctor-protection.test.ts | 63 +++ tests/codex-log-guard-lock.test.ts | 31 ++ tests/codex-log-guard-policy.test.ts | 33 ++ tests/codex-log-guard-processes.test.ts | 59 +++ tests/codex-log-guard-protection.test.ts | 249 ++++++++++ .../codex-log-guard-status-zero-write.test.ts | 112 +++++ 28 files changed, 2747 insertions(+), 124 deletions(-) create mode 100644 gui/tests/storage-log-guard-protection.test.tsx create mode 100644 src/codex/log-guard/lock.ts create mode 100644 src/codex/log-guard/path-safety.ts create mode 100644 src/codex/log-guard/policy.ts create mode 100644 src/codex/log-guard/processes.ts create mode 100644 src/codex/log-guard/protection.ts create mode 100644 src/codex/log-guard/sqlite-errors.ts create mode 100644 tests/api-codex-log-guard-protection.test.ts create mode 100644 tests/cli-codex-log-guard-protection.test.ts create mode 100644 tests/codex-app-server-path-spaces.test.ts create mode 100644 tests/codex-log-guard-coderabbit.test.ts create mode 100644 tests/codex-log-guard-doctor-coderabbit.test.ts create mode 100644 tests/codex-log-guard-doctor-protection.test.ts create mode 100644 tests/codex-log-guard-lock.test.ts create mode 100644 tests/codex-log-guard-policy.test.ts create mode 100644 tests/codex-log-guard-processes.test.ts create mode 100644 tests/codex-log-guard-protection.test.ts create mode 100644 tests/codex-log-guard-status-zero-write.test.ts diff --git a/docs-site/src/content/docs/guides/codex-log-guard.md b/docs-site/src/content/docs/guides/codex-log-guard.md index b162b4820f..ada50d5c3b 100644 --- a/docs-site/src/content/docs/guides/codex-log-guard.md +++ b/docs-site/src/content/docs/guides/codex-log-guard.md @@ -1,9 +1,9 @@ --- title: Codex Log Guard -description: Inspect Codex diagnostic-log storage safely before enabling future protection or reclaim actions. +description: Inspect and explicitly reduce Codex diagnostic-log persistence without exposing log bodies. --- -OpenCodex can inspect Codex's persistent diagnostic-log database from the **Storage** page and from the CLI. The inspection surface is deliberately read-only: it does not install triggers, delete logs, checkpoint SQLite, vacuum the database, or change Codex configuration. +OpenCodex can inspect Codex's persistent diagnostic-log database and, when you opt in, reduce which diagnostic rows Codex persists. Inspection stays read-only; protection is an explicit mutation that is refused unless the known Codex log schema is present and Codex is stopped. ## What Inspect reports @@ -21,49 +21,121 @@ If `sqlite_home` is outside `CODEX_HOME`, the diagnostic database is shown separ OpenCodex does not select or expose `feedback_log_body` while producing these diagnostics. Log levels are reduced to the fixed known level set plus `OTHER`, and target names are not serialized. +## Protect modes + +Protection is **off by default**. Enabling it installs one OpenCodex-owned `BEFORE INSERT` trigger in Codex's canonical `logs_2.sqlite` database. OpenCodex never replaces an unknown trigger using its reserved names and removes only triggers whose SQL matches the OpenCodex-owned version. + +Two modes are available: + +- **Compatibility** (`compat`) is the recommended mode. It pins the current Log Guard v1 rule set to the high-volume targets that current Codex already filters or down-levels in its persistent SQLite log sink. Unrelated `TRACE` rows are preserved. +- **Quiet** (`quiet`) suppresses every new `TRACE` row while preserving `DEBUG`, `INFO`, `WARN`, and `ERROR` rows. + +Protection reduces rows that reach persistent SQLite storage. It does **not** eliminate Codex's earlier tracing work: events can still be formatted, queued, grouped into transactions, and considered by Codex's own pruning logic before the trigger ignores a row. Treat Protect as a persistent-write-churn shield, not as a switch that disables diagnostic generation inside Codex. + +Log Guard filters only persisted local SQLite log rows. It does not change Codex diagnostic processing, [adapter transport](/reference/adapters/), provider payloads, streaming semantics, authentication, routing, quotas, or account state. + +### Safety checks + +Before Protect, Disable, or Repair changes the foreign database, OpenCodex: + +1. resolves exactly the canonical `logs_2.sqlite` path; +2. verifies that the path is a regular, non-symlink file and that the known schema matches exactly; +3. verifies that process enumeration succeeded and no supported Codex writer process is running; +4. acquires a dedicated cross-process Log Guard lock; +5. repeats the Codex-process check after acquiring that lock; +6. opens the database read/write **without** create semantics and acquires SQLite `BEGIN IMMEDIATE` with no busy wait; +7. changes only OpenCodex-owned Log Guard triggers and reads the result back before commit; and +8. persists the requested mode in OpenCodex configuration while the Log Guard lock is still held. + +If process enumeration is uncertain, the database is busy, the schema is unknown, or a reserved trigger name belongs to different SQL, the mutation fails closed. OpenCodex does not terminate Codex automatically. + +## Drift and Repair + +The requested protection mode is stored in OpenCodex configuration separately from Codex's log database. This matters because a Codex migration can rebuild the `logs` table, and SQLite drops triggers attached to a table that is replaced. + +When the saved mode is `compat` or `quiet` but the corresponding owned trigger is no longer observed, Log Guard reports **drifted**. `ocx doctor` reports the drift but never repairs it automatically. + +Repair is explicit: + +```bash +ocx storage codex-logs repair +``` + +OpenCodex deliberately does not recreate protection on every startup. A later release can reconsider automatic repair only after there is enough field evidence that doing so is safe across Codex migrations. + ## CLI +Read status: + ```bash ocx storage codex-logs status ocx storage codex-logs status --json ocx doctor ``` +Enable the recommended compatibility policy: + +```bash +ocx storage codex-logs protect +``` + +Choose quiet mode explicitly: + +```bash +ocx storage codex-logs protect --mode quiet +``` + +Disable OpenCodex protection or repair drift: + +```bash +ocx storage codex-logs unprotect +ocx storage codex-logs repair +``` + +Add `--json` to the Log Guard commands for machine-readable output. See the [CLI reference](/reference/cli/) for the canonical command syntax and JSON behaviour. + The existing command remains unchanged: ```bash ocx storage --json ``` -Its response now also carries the same Codex-log inspection report used by the Storage page. +Its response carries the same Codex-log status used by the Storage page. ## Management API +Status is available at: + ```text GET /api/storage/codex-logs ``` -`GET /api/storage` also includes the report as `codexLogs` so the dashboard can refresh the normal storage breakdown and Codex-log diagnostics from one snapshot request. +Explicit mutations use: -## Read-only snapshot semantics +```text +POST /api/storage/codex-logs/protect +POST /api/storage/codex-logs/unprotect +POST /api/storage/codex-logs/repair +``` -Inspection opens the database read-only with SQLite `immutable=1`. This prevents the diagnostic read itself from creating or updating `-wal` or `-shm` sidecars. +The Protect body is either `{"mode":"compat"}` or `{"mode":"quiet"}`. `GET /api/storage` also includes the report as `codexLogs` so the dashboard can refresh the normal storage breakdown and Codex-log diagnostics from one snapshot request. -The trade-off is important: SQL aggregates describe the last checkpointed database snapshot. If Codex is actively writing, the live WAL can contain newer rows than the aggregate counts. OpenCodex therefore reports the WAL file size separately and does **not** label the result as SSD write rate, NAND writes, or drive-wear/TBW consumption. +## Read-only snapshot semantics -## Compatibility states +Status inspection opens the database read-only with SQLite `immutable=1`. This prevents a diagnostic read from creating or updating `-wal` or `-shm` sidecars. -A known schema reports inspection, future protection, and future reclaim capabilities as supported. A missing, unreadable, or unknown future schema remains inspectable as metadata but is reported as unsupported for mutation-capable operations. +The trade-off is important: SQL aggregates and observed trigger metadata describe the last checkpointed database snapshot. If Codex is actively writing, the live WAL can contain newer rows or schema pages than the immutable snapshot. A successful mutation response uses the trigger state that OpenCodex verified inside its write transaction; a later read-only status request can temporarily lag until SQLite checkpoints those schema pages. -An unknown schema is not guessed into compatibility. This lets a newer Codex version remain observable while preventing later Log Guard releases from treating an unreviewed database layout as safe to modify. +OpenCodex reports the WAL file size separately and does **not** label the result as SSD write rate, NAND writes, or drive-wear/TBW consumption. + +## Compatibility states -## What is not in this stage +A known schema reports inspection and protection as supported. A missing, unreadable, or unknown future schema remains inspectable as metadata but is reported as unsupported for mutation-capable operations. -This is **Inspect**, the first Log Guard stage. It does not reduce Codex writes by itself and does not reclaim database pages. +An unknown schema is not guessed into compatibility. This lets a newer Codex version remain observable while preventing Log Guard from treating an unreviewed database layout as safe to modify. -Later stages are intentionally separate: +## Reclaim is still separate -- **Protect** will add explicit write-reduction modes after safety checks and Codex-process quiescence. -- **Reclaim** will add explicit, offline, bounded SQLite space reclamation. +Protect does not vacuum or compact SQLite. The later **Reclaim** stage will add an explicit, offline, bounded incremental-vacuum flow with checkpoints and integrity checks. -Neither action is automatically enabled by Inspect. +Protect never runs `VACUUM`, never truncates or deletes Codex's WAL directly, and never performs scheduled space reclamation. diff --git a/gui/src/components/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx index 9ae40cf0b3..7f57ab8c06 100644 --- a/gui/src/components/storage-workspace/StorageWorkspace.tsx +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -1,5 +1,5 @@ /** - * StorageWorkspace — rail + main workspace for the Storage tab, mirroring the + * StorageWorkspace - rail + main workspace for the Storage tab, mirroring the * Providers workspace DNA. Left rail lists buckets sorted by size; the main pane * shows either the overview (totals + largest files across buckets) or a * per-bucket detail view. @@ -9,9 +9,15 @@ import { useMemo, useState } from "react"; import { IconChevron, IconHardDrive } from "../../icons"; import { useT, type TFn, type TKey, type Locale } from "../../i18n/shared"; import { logGuardLabel } from "../../i18n/log-guard-labels"; -import { logGuardSchemaStateLabel } from "../../i18n/log-guard-state-labels"; +import { + logGuardProtectionModeLabel, + logGuardProtectionStateLabel, + logGuardSchemaStateLabel, +} from "../../i18n/log-guard-state-labels"; import { formatBytes } from "../../format-bytes"; +const API_BASE = import.meta.env.VITE_API_BASE || ""; + export interface StorageLargestEntry { path: string; bytes: number; @@ -36,6 +42,12 @@ type LogGuardSchema = | { state: "unreadable"; reason: "database_unreadable" } | { state: "unsupported"; reason: "unknown_schema" }; +export interface CodexLogGuardProtection { + desiredMode: "off" | "compat" | "quiet"; + observedMode: "off" | "compat" | "quiet" | "collision"; + state: "off" | "active" | "drifted" | "unsupported" | "unknown"; +} + export interface CodexLogGuardReport { generatedAt: number; externalSqliteHome: boolean; @@ -47,6 +59,7 @@ export interface CodexLogGuardReport { protection: LogGuardCapability; reclaim: LogGuardCapability; }; + protection?: CodexLogGuardProtection; metrics: null | { totalRows: number; rowsByLevel: Record; @@ -71,7 +84,12 @@ export interface StorageReport { error?: string; } -// Known scanner bucket keys → localized labels; unknown future keys fall back to the API label. +export type CodexLogGuardAction = + | { action: "protect"; mode: "compat" | "quiet" } + | { action: "unprotect" } + | { action: "repair" }; + +// Known scanner bucket keys -> localized labels; unknown future keys fall back to the API label. const BUCKET_TKEYS: Record = { sessions: "storage.bucket.sessions", archived_sessions: "storage.bucket.archived_sessions", @@ -97,10 +115,40 @@ function rowsDisplay(bucket: StorageBucket, locale: Locale, t: TFn): string { return bucket.rows.toLocaleString(locale); } -function CodexLogGuardPanel({ report, locale, t }: { report: CodexLogGuardReport; locale: Locale; t: TFn }) { +function mutationErrorLabel(locale: Locale, code: unknown): string { + switch (code) { + case "codex_running": return logGuardLabel(locale, "error.codex_running"); + case "process_enumeration_failed": return logGuardLabel(locale, "error.process_enumeration_failed"); + case "busy": return logGuardLabel(locale, "error.busy"); + case "unsupported_schema": return logGuardLabel(locale, "error.unsupported_schema"); + case "trigger_collision": return logGuardLabel(locale, "error.trigger_collision"); + case "unsafe_path": return logGuardLabel(locale, "error.unsafe_path"); + case "database_error": return logGuardLabel(locale, "error.database_error"); + case "config_write_failed": return logGuardLabel(locale, "error.config_write_failed"); + default: return logGuardLabel(locale, "error.generic"); + } +} + +function CodexLogGuardPanel({ + report, + locale, + t, + busy, + error, + onAction, +}: { + report: CodexLogGuardReport; + locale: Locale; + t: TFn; + busy: boolean; + error: string | null; + onAction: (action: CodexLogGuardAction) => void; +}) { const metrics = report.metrics; const inspectOnly = report.capabilities.protection.state === "unsupported" || report.capabilities.reclaim.state === "unsupported"; + const protection = report.protection; + const mutationDisabled = busy || report.capabilities.protection.state !== "supported"; return (
@@ -148,6 +196,64 @@ function CodexLogGuardPanel({ report, locale, t }: { report: CodexLogGuardReport
+ + {protection && ( +
+

{logGuardLabel(locale, "protection")}

+
+ {logGuardProtectionStateLabel(locale, protection.state)} + + {logGuardProtectionModeLabel(locale, protection.desiredMode)} + {protection.observedMode !== protection.desiredMode ? <>{logGuardProtectionModeLabel(locale, protection.observedMode)} : null} + +
+
+ + + + {protection.state === "drifted" && ( + + )} + {busy && {logGuardLabel(locale, "applying")}} +
+ {error &&

{error}

} +
+ )} + {metrics && metrics.topTargets.length > 0 && (

target

@@ -176,17 +282,44 @@ function CodexLogGuardUnavailablePanel({ locale, t }: { locale: Locale; t: TFn } export interface StorageWorkspaceProps { report: StorageReport; locale: Locale; + logGuardBusy?: boolean; + onLogGuardAction?: (action: CodexLogGuardAction) => void; } -export default function StorageWorkspace({ report, locale }: StorageWorkspaceProps) { +type GenerationScopedLogGuardReport = { + generation: number; + report: CodexLogGuardReport; +}; + +type GenerationScopedError = { + generation: number; + message: string; +}; + +export default function StorageWorkspace({ + report, + locale, + logGuardBusy = false, + onLogGuardAction, +}: StorageWorkspaceProps) { const t = useT(); const [selectedKey, setSelectedKey] = useState(null); + const [logGuardOverride, setLogGuardOverride] = useState(null); + const [internalLogGuardBusy, setInternalLogGuardBusy] = useState(false); + const [logGuardError, setLogGuardError] = useState(null); const sortedBuckets = useMemo( () => report.buckets.toSorted((a, b) => b.bytes - a.bytes), [report.buckets], ); const selected = sortedBuckets.find(b => b.key === selectedKey) ?? null; + const displayedLogGuard = logGuardOverride?.generation === report.generatedAt + ? logGuardOverride.report + : report.codexLogs ?? null; + const displayedLogGuardError = logGuardError?.generation === report.generatedAt + ? logGuardError.message + : null; + const effectiveLogGuardBusy = logGuardBusy || internalLogGuardBusy; const largestAcross = useMemo(() => { const rows: Array = []; @@ -201,6 +334,41 @@ export default function StorageWorkspace({ report, locale }: StorageWorkspacePro [report.buckets], ); + const runLogGuardAction = (action: CodexLogGuardAction) => { + if (onLogGuardAction) { + onLogGuardAction(action); + return; + } + if (internalLogGuardBusy) return; + const generation = report.generatedAt; + void (async () => { + setInternalLogGuardBusy(true); + setLogGuardError(null); + try { + const suffix = action.action === "protect" ? "protect" : action.action; + const init: RequestInit = { + method: "POST", + ...(action.action === "protect" ? { + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: action.mode }), + } : {}), + }; + const response = await fetch(`${API_BASE}/api/storage/codex-logs/${suffix}`, init); + if (!response.ok) { + const errorPayload = await response.json().catch(() => ({})) as Record; + setLogGuardError({ generation, message: mutationErrorLabel(locale, errorPayload.error) }); + return; + } + const payload = await response.json() as CodexLogGuardReport; + setLogGuardOverride({ generation, report: payload }); + } catch { + setLogGuardError({ generation, message: logGuardLabel(locale, "error.generic") }); + } finally { + setInternalLogGuardBusy(false); + } + })(); + }; + return (
- {report.codexLogs ? ( - + {displayedLogGuard ? ( + ) : report.codexLogsError === "inspect_failed" ? ( ) : null} diff --git a/gui/src/i18n/log-guard-labels.ts b/gui/src/i18n/log-guard-labels.ts index dc453b5818..dab2fafd44 100644 --- a/gui/src/i18n/log-guard-labels.ts +++ b/gui/src/i18n/log-guard-labels.ts @@ -1,52 +1,205 @@ import type { Locale } from "./catalogs"; -export type LogGuardLabelKey = "inspectionOnly" | "externalSqliteHome" | "inspectionUnavailable"; +export type LogGuardLabelKey = + | "inspectionOnly" + | "externalSqliteHome" + | "inspectionUnavailable" + | "protection" + | "compat" + | "quiet" + | "disable" + | "repair" + | "applying" + | "error.generic" + | "error.codex_running" + | "error.process_enumeration_failed" + | "error.busy" + | "error.unsupported_schema" + | "error.trigger_collision" + | "error.unsafe_path" + | "error.database_error" + | "error.config_write_failed"; const LABELS: Record> = { en: { - inspectionOnly: "Inspection only", - externalSqliteHome: "External SQLite storage", + inspectionOnly: 'Inspection only', + externalSqliteHome: 'External SQLite storage', inspectionUnavailable: "Diagnostic log inspection is unavailable.", + protection: "Protection", + compat: "Compatibility", + quiet: "Quiet", + disable: "Disable protection", + repair: "Repair protection", + applying: "Applying protection…", + "error.generic": "Could not change Codex log protection.", + "error.codex_running": "Quit Codex before changing log protection.", + "error.process_enumeration_failed": "Could not verify that Codex is stopped. Protection was not changed.", + "error.busy": "The Codex logs database is busy. Quit Codex and try again.", + "error.unsupported_schema": "This Codex logs schema is not supported for protection.", + "error.trigger_collision": "A reserved Log Guard trigger name is already in use. Protection was not changed.", + "error.unsafe_path": "The Codex logs database path failed the safety check.", + "error.database_error": "Could not update the Codex logs database.", + "error.config_write_failed": "The database changed, but OpenCodex could not save the protection setting. Fix config storage, then run Repair.", }, de: { - inspectionOnly: "Nur Inspektion", - externalSqliteHome: "Externer SQLite-Speicher", + inspectionOnly: 'Nur Inspektion', + externalSqliteHome: 'Externer SQLite-Speicher', inspectionUnavailable: "Die Diagnoseprotokoll-Inspektion ist nicht verfügbar.", + protection: "Schutz", + compat: "Kompatibilität", + quiet: "Leise", + disable: "Schutz deaktivieren", + repair: "Schutz reparieren", + applying: "Schutz wird angewendet…", + "error.generic": "Der Schutz der Codex-Protokolle konnte nicht geändert werden.", + "error.codex_running": "Beende Codex, bevor du den Protokollschutz änderst.", + "error.process_enumeration_failed": "Es konnte nicht sicher festgestellt werden, dass Codex beendet ist. Der Schutz wurde nicht geändert.", + "error.busy": "Die Codex-Protokolldatenbank ist belegt. Beende Codex und versuche es erneut.", + "error.unsupported_schema": "Dieses Schema der Codex-Protokolldatenbank wird für den Schutz nicht unterstützt.", + "error.trigger_collision": "Ein reservierter Log-Guard-Triggername wird bereits verwendet. Der Schutz wurde nicht geändert.", + "error.unsafe_path": "Der Pfad der Codex-Protokolldatenbank hat die Sicherheitsprüfung nicht bestanden.", + "error.database_error": "Die Codex-Protokolldatenbank konnte nicht aktualisiert werden.", + "error.config_write_failed": "Die Datenbank wurde geändert, aber OpenCodex konnte die Schutzeinstellung nicht speichern. Repariere den Konfigurationsspeicher und führe danach Reparieren aus.", }, fr: { inspectionOnly: "Inspection uniquement", externalSqliteHome: "Stockage SQLite externe", inspectionUnavailable: "L’inspection des journaux de diagnostic est indisponible.", + protection: "Protection", + compat: "Compatibilité", + quiet: "Silencieux", + disable: "Désactiver la protection", + repair: "Réparer la protection", + applying: "Application de la protection…", + "error.generic": "Impossible de modifier la protection des journaux Codex.", + "error.codex_running": "Quittez Codex avant de modifier la protection des journaux.", + "error.process_enumeration_failed": "Impossible de vérifier que Codex est arrêté. La protection n’a pas été modifiée.", + "error.busy": "La base de données des journaux Codex est occupée. Quittez Codex et réessayez.", + "error.unsupported_schema": "Ce schéma de journaux Codex n’est pas pris en charge pour la protection.", + "error.trigger_collision": "Un nom de déclencheur Log Guard réservé est déjà utilisé. La protection n’a pas été modifiée.", + "error.unsafe_path": "Le chemin de la base de données des journaux Codex a échoué au contrôle de sécurité.", + "error.database_error": "Impossible de mettre à jour la base de données des journaux Codex.", + "error.config_write_failed": "La base de données a été modifiée, mais OpenCodex n’a pas pu enregistrer le paramètre de protection. Corrigez le stockage de configuration, puis lancez Réparer.", }, ko: { - inspectionOnly: "검사 전용", - externalSqliteHome: "외부 SQLite 저장소", + inspectionOnly: '검사 전용', + externalSqliteHome: '외부 SQLite 저장소', inspectionUnavailable: "진단 로그 검사를 사용할 수 없습니다.", + protection: "보호", + compat: "호환 모드", + quiet: "조용한 모드", + disable: "보호 비활성화", + repair: "보호 복구", + applying: "보호 적용 중…", + "error.generic": "Codex 로그 보호를 변경하지 못했습니다.", + "error.codex_running": "로그 보호를 변경하기 전에 Codex를 종료하세요.", + "error.process_enumeration_failed": "Codex가 종료되었는지 확인할 수 없어 보호를 변경하지 않았습니다.", + "error.busy": "Codex 로그 데이터베이스가 사용 중입니다. Codex를 종료한 뒤 다시 시도하세요.", + "error.unsupported_schema": "이 Codex 로그 스키마는 보호 기능을 지원하지 않습니다.", + "error.trigger_collision": "예약된 Log Guard 트리거 이름이 이미 사용 중입니다. 보호를 변경하지 않았습니다.", + "error.unsafe_path": "Codex 로그 데이터베이스 경로가 안전성 검사를 통과하지 못했습니다.", + "error.database_error": "Codex 로그 데이터베이스를 업데이트하지 못했습니다.", + "error.config_write_failed": "데이터베이스는 변경되었지만 OpenCodex가 보호 설정을 저장하지 못했습니다. 구성 저장소를 수정한 뒤 복구를 실행하세요.", }, zh: { - inspectionOnly: "仅检查", - externalSqliteHome: "外部 SQLite 存储", + inspectionOnly: '仅检查', + externalSqliteHome: '外部 SQLite 存储', inspectionUnavailable: "诊断日志检查当前不可用。", + protection: "保护", + compat: "兼容模式", + quiet: "静默模式", + disable: "禁用保护", + repair: "修复保护", + applying: "正在应用保护…", + "error.generic": "无法更改 Codex 日志保护。", + "error.codex_running": "更改日志保护前请先退出 Codex。", + "error.process_enumeration_failed": "无法确认 Codex 已停止,因此未更改保护设置。", + "error.busy": "Codex 日志数据库正忙。请退出 Codex 后重试。", + "error.unsupported_schema": "此 Codex 日志数据库结构不支持保护功能。", + "error.trigger_collision": "保留的 Log Guard 触发器名称已被占用,因此未更改保护设置。", + "error.unsafe_path": "Codex 日志数据库路径未通过安全检查。", + "error.database_error": "无法更新 Codex 日志数据库。", + "error.config_write_failed": "数据库已更改,但 OpenCodex 无法保存保护设置。请先修复配置存储,然后运行“修复保护”。", }, "zh-TW": { - inspectionOnly: "僅檢查", - externalSqliteHome: "外部 SQLite 儲存空間", + inspectionOnly: '僅檢查', + externalSqliteHome: '外部 SQLite 儲存空間', inspectionUnavailable: "診斷記錄檢查目前無法使用。", + protection: "保護", + compat: "相容模式", + quiet: "靜默模式", + disable: "停用保護", + repair: "修復保護", + applying: "正在套用保護…", + "error.generic": "無法變更 Codex 日誌保護。", + "error.codex_running": "變更日誌保護前請先退出 Codex。", + "error.process_enumeration_failed": "無法確認 Codex 已停止,因此未變更保護設定。", + "error.busy": "Codex 日誌資料庫忙碌中。請退出 Codex 後重試。", + "error.unsupported_schema": "此 Codex 日誌資料庫結構不支援保護功能。", + "error.trigger_collision": "保留的 Log Guard 觸發器名稱已被使用,因此未變更保護設定。", + "error.unsafe_path": "Codex 日誌資料庫路徑未通過安全檢查。", + "error.database_error": "無法更新 Codex 日誌資料庫。", + "error.config_write_failed": "資料庫已變更,但 OpenCodex 無法儲存保護設定。請先修復設定儲存空間,再執行「修復保護」。", }, ru: { - inspectionOnly: "Только проверка", - externalSqliteHome: "Внешнее хранилище SQLite", + inspectionOnly: 'Только проверка', + externalSqliteHome: 'Внешнее хранилище SQLite', inspectionUnavailable: "Проверка диагностических журналов недоступна.", + protection: "Защита", + compat: "Совместимость", + quiet: "Тихий режим", + disable: "Отключить защиту", + repair: "Восстановить защиту", + applying: "Применение защиты…", + "error.generic": "Не удалось изменить защиту журналов Codex.", + "error.codex_running": "Закройте Codex перед изменением защиты журналов.", + "error.process_enumeration_failed": "Не удалось убедиться, что Codex остановлен. Защита не изменена.", + "error.busy": "База журналов Codex занята. Закройте Codex и повторите попытку.", + "error.unsupported_schema": "Эта схема базы журналов Codex не поддерживает защиту.", + "error.trigger_collision": "Зарезервированное имя триггера Log Guard уже используется. Защита не изменена.", + "error.unsafe_path": "Путь к базе журналов Codex не прошёл проверку безопасности.", + "error.database_error": "Не удалось обновить базу журналов Codex.", + "error.config_write_failed": "База была изменена, но OpenCodex не смог сохранить настройку защиты. Исправьте хранилище конфигурации и затем запустите восстановление.", }, ja: { - inspectionOnly: "検査のみ", - externalSqliteHome: "外部 SQLite ストレージ", + inspectionOnly: '検査のみ', + externalSqliteHome: '外部 SQLite ストレージ', inspectionUnavailable: "診断ログの検査を利用できません。", + protection: "保護", + compat: "互換モード", + quiet: "静音モード", + disable: "保護を無効化", + repair: "保護を修復", + applying: "保護を適用中…", + "error.generic": "Codex ログ保護を変更できませんでした。", + "error.codex_running": "ログ保護を変更する前に Codex を終了してください。", + "error.process_enumeration_failed": "Codex が停止していることを確認できなかったため、保護は変更されませんでした。", + "error.busy": "Codex ログデータベースが使用中です。Codex を終了して再試行してください。", + "error.unsupported_schema": "この Codex ログスキーマでは保護機能を使用できません。", + "error.trigger_collision": "予約済みの Log Guard トリガー名が既に使用されています。保護は変更されませんでした。", + "error.unsafe_path": "Codex ログデータベースのパスが安全性チェックに失敗しました。", + "error.database_error": "Codex ログデータベースを更新できませんでした。", + "error.config_write_failed": "データベースは変更されましたが、OpenCodex は保護設定を保存できませんでした。設定ストレージを修正してから保護を修復してください。", }, tr: { - inspectionOnly: "Yalnızca inceleme", - externalSqliteHome: "Harici SQLite depolaması", + inspectionOnly: 'Yalnızca inceleme', + externalSqliteHome: 'Harici SQLite depolaması', inspectionUnavailable: "Tanılama günlüğü incelemesi kullanılamıyor.", + protection: "Koruma", + compat: "Uyumluluk", + quiet: "Sessiz", + disable: "Korumayı devre dışı bırak", + repair: "Korumayı onar", + applying: "Koruma uygulanıyor…", + "error.generic": "Codex günlük koruması değiştirilemedi.", + "error.codex_running": "Günlük korumasını değiştirmeden önce Codex'i kapatın.", + "error.process_enumeration_failed": "Codex'in kapalı olduğu doğrulanamadı. Koruma değiştirilmedi.", + "error.busy": "Codex günlük veritabanı meşgul. Codex'i kapatıp yeniden deneyin.", + "error.unsupported_schema": "Bu Codex günlük şeması koruma için desteklenmiyor.", + "error.trigger_collision": "Ayrılmış bir Log Guard tetikleyici adı zaten kullanılıyor. Koruma değiştirilmedi.", + "error.unsafe_path": "Codex günlük veritabanı yolu güvenlik denetimini geçemedi.", + "error.database_error": "Codex günlük veritabanı güncellenemedi.", + "error.config_write_failed": "Veritabanı değişti ancak OpenCodex koruma ayarını kaydedemedi. Yapılandırma depolamasını düzeltip ardından korumayı onarın.", }, }; diff --git a/gui/src/i18n/log-guard-state-labels.ts b/gui/src/i18n/log-guard-state-labels.ts index 8ba6d7711d..176f1778c6 100644 --- a/gui/src/i18n/log-guard-state-labels.ts +++ b/gui/src/i18n/log-guard-state-labels.ts @@ -1,64 +1,71 @@ import type { Locale } from "./catalogs"; export type LogGuardSchemaState = "compatible" | "missing" | "unreadable" | "unsupported"; +export type LogGuardProtectionState = "off" | "active" | "drifted" | "unsupported" | "unknown"; +export type LogGuardProtectionMode = "off" | "compat" | "quiet" | "collision"; -const SCHEMA_LABELS: Record> = { +type StateLabels = { + schema: Record; + protection: Record; + mode: Record; +}; + +const LABELS: Record = { en: { - compatible: "Compatible", - missing: "Database not found", - unreadable: "Database unavailable", - unsupported: "Unsupported", + schema: { compatible: "Compatible", missing: "Database not found", unreadable: "Database unavailable", unsupported: "Unsupported" }, + protection: { off: "Off", active: "Active", drifted: "Needs repair", unsupported: "Unsupported", unknown: "Unknown" }, + mode: { off: "Off", compat: "Compatibility", quiet: "Quiet", collision: "Unknown" }, }, de: { - compatible: "Kompatibel", - missing: "Datenbank nicht gefunden", - unreadable: "Datenbank nicht lesbar", - unsupported: "Nicht unterstützt", + schema: { compatible: "Kompatibel", missing: "Datenbank nicht gefunden", unreadable: "Datenbank nicht lesbar", unsupported: "Nicht unterstützt" }, + protection: { off: "Aus", active: "Aktiv", drifted: "Reparatur erforderlich", unsupported: "Nicht unterstützt", unknown: "Unbekannt" }, + mode: { off: "Aus", compat: "Kompatibilität", quiet: "Leise", collision: "Unbekannt" }, }, fr: { - compatible: "Compatible", - missing: "Base de données introuvable", - unreadable: "Base de données indisponible", - unsupported: "Non pris en charge", + schema: { compatible: "Compatible", missing: "Base de données introuvable", unreadable: "Base de données indisponible", unsupported: "Non pris en charge" }, + protection: { off: "Désactivée", active: "Active", drifted: "Réparation requise", unsupported: "Non prise en charge", unknown: "Inconnue" }, + mode: { off: "Désactivé", compat: "Compatibilité", quiet: "Silencieux", collision: "Inconnu" }, }, ko: { - compatible: "호환됨", - missing: "데이터베이스 없음", - unreadable: "데이터베이스를 읽을 수 없음", - unsupported: "지원되지 않음", + schema: { compatible: "호환됨", missing: "데이터베이스 없음", unreadable: "데이터베이스를 읽을 수 없음", unsupported: "지원되지 않음" }, + protection: { off: "꺼짐", active: "활성", drifted: "복구 필요", unsupported: "지원되지 않음", unknown: "알 수 없음" }, + mode: { off: "꺼짐", compat: "호환 모드", quiet: "조용한 모드", collision: "알 수 없음" }, }, zh: { - compatible: "兼容", - missing: "未找到数据库", - unreadable: "无法读取数据库", - unsupported: "不受支持", + schema: { compatible: "兼容", missing: "未找到数据库", unreadable: "无法读取数据库", unsupported: "不受支持" }, + protection: { off: "关闭", active: "已启用", drifted: "需要修复", unsupported: "不受支持", unknown: "未知" }, + mode: { off: "关闭", compat: "兼容模式", quiet: "静默模式", collision: "未知" }, }, "zh-TW": { - compatible: "相容", - missing: "找不到資料庫", - unreadable: "無法讀取資料庫", - unsupported: "不支援", + schema: { compatible: "相容", missing: "找不到資料庫", unreadable: "無法讀取資料庫", unsupported: "不支援" }, + protection: { off: "關閉", active: "已啟用", drifted: "需要修復", unsupported: "不支援", unknown: "未知" }, + mode: { off: "關閉", compat: "相容模式", quiet: "靜默模式", collision: "未知" }, }, ru: { - compatible: "Совместимо", - missing: "База не найдена", - unreadable: "База недоступна", - unsupported: "Не поддерживается", + schema: { compatible: "Совместимо", missing: "База не найдена", unreadable: "База недоступна", unsupported: "Не поддерживается" }, + protection: { off: "Выключено", active: "Активно", drifted: "Требуется восстановление", unsupported: "Не поддерживается", unknown: "Неизвестно" }, + mode: { off: "Выключено", compat: "Совместимость", quiet: "Тихий режим", collision: "Неизвестно" }, }, ja: { - compatible: "互換", - missing: "データベースが見つかりません", - unreadable: "データベースを読み取れません", - unsupported: "未対応", + schema: { compatible: "互換", missing: "データベースが見つかりません", unreadable: "データベースを読み取れません", unsupported: "未対応" }, + protection: { off: "オフ", active: "有効", drifted: "修復が必要", unsupported: "未対応", unknown: "不明" }, + mode: { off: "オフ", compat: "互換モード", quiet: "静音モード", collision: "不明" }, }, tr: { - compatible: "Uyumlu", - missing: "Veritabanı bulunamadı", - unreadable: "Veritabanı okunamıyor", - unsupported: "Desteklenmiyor", + schema: { compatible: "Uyumlu", missing: "Veritabanı bulunamadı", unreadable: "Veritabanı okunamıyor", unsupported: "Desteklenmiyor" }, + protection: { off: "Kapalı", active: "Etkin", drifted: "Onarım gerekli", unsupported: "Desteklenmiyor", unknown: "Bilinmiyor" }, + mode: { off: "Kapalı", compat: "Uyumluluk", quiet: "Sessiz", collision: "Bilinmiyor" }, }, }; export function logGuardSchemaStateLabel(locale: Locale, state: LogGuardSchemaState): string { - return SCHEMA_LABELS[locale][state]; + return LABELS[locale].schema[state]; +} + +export function logGuardProtectionStateLabel(locale: Locale, state: LogGuardProtectionState): string { + return LABELS[locale].protection[state]; +} + +export function logGuardProtectionModeLabel(locale: Locale, mode: LogGuardProtectionMode): string { + return LABELS[locale].mode[mode]; } diff --git a/gui/tests/storage-log-guard-protection.test.tsx b/gui/tests/storage-log-guard-protection.test.tsx new file mode 100644 index 0000000000..67507fdce3 --- /dev/null +++ b/gui/tests/storage-log-guard-protection.test.tsx @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import StorageWorkspace, { type StorageReport } from "../src/components/storage-workspace/StorageWorkspace"; +import { LanguageProvider } from "../src/i18n/provider"; +import { DICTS, I18nContext, interpolate, type TFn } from "../src/i18n/shared"; + +function report(state: "off" | "active" | "drifted" = "active"): StorageReport { + const desiredMode = state === "off" ? "off" : "compat"; + const observedMode = state === "active" ? "compat" : "off"; + return { + codexHome: "/home/user/.codex", + generatedAt: 1, + total: { bytes: 1024, fileCount: 1 }, + buckets: [], + codexLogs: { + generatedAt: 1, + externalSqliteHome: false, + snapshot: "checkpointed", + files: { databaseBytes: 8192, walBytes: 0, shmBytes: 0 }, + schema: { state: "compatible" }, + capabilities: { + inspection: { state: "supported" }, + protection: { state: "supported" }, + reclaim: { state: "supported" }, + }, + protection: { desiredMode, observedMode, state }, + metrics: null, + }, + } satisfies StorageReport; +} + +function render(value: StorageReport): string { + return renderToStaticMarkup( + + {}} + /> + , + ); +} + +function germanT(): TFn { + return (key, vars) => interpolate(DICTS.de[key] ?? DICTS.en[key] ?? key, vars); +} + +test("Storage exposes compatibility, quiet, and disable protection controls", () => { + const html = render(report("active")); + expect(html).toContain('data-testid="log-guard-protection"'); + expect(html).toContain("Protection"); + expect(html).toContain("Compatibility"); + expect(html).toContain("Quiet"); + expect(html).toContain("Disable protection"); + expect(html).toContain("Active"); + expect(html).not.toContain(">active<"); + expect(html).not.toContain("Compact"); +}); + +test("Storage exposes explicit repair only when protection drifted", () => { + const html = render(report("drifted")); + expect(html).toContain("Needs repair"); + expect(html).not.toContain(">drifted<"); + expect(html).toContain("Repair protection"); +}); + +test("Storage localizes protection state and desired/observed modes", () => { + const html = renderToStaticMarkup( + {}, t: germanT() }}> + {}} + /> + , + ); + + expect(html).toContain("Reparatur erforderlich"); + expect(html).toContain("Kompatibilität"); + expect(html).toContain("Aus"); + expect(html).not.toContain(">drifted<"); + expect(html).not.toContain(">compat<"); +}); + +test("Storage disables protection mutation controls for an unsupported schema", () => { + const value = report("off"); + value.codexLogs = { + ...value.codexLogs!, + schema: { state: "unsupported", reason: "unknown_schema" }, + capabilities: { + inspection: { state: "supported" }, + protection: { state: "unsupported", reason: "unknown_schema" }, + reclaim: { state: "unsupported", reason: "unknown_schema" }, + }, + }; + const html = render(value); + expect(html).toMatch(/data-testid="log-guard-protect-compat"[^>]*disabled/); + expect(html).toMatch(/data-testid="log-guard-protect-quiet"[^>]*disabled/); + expect(html).not.toContain("Compact"); +}); diff --git a/src/cli/codex-log-guard-doctor.ts b/src/cli/codex-log-guard-doctor.ts index 655a58fdad..bea8a3787a 100644 --- a/src/cli/codex-log-guard-doctor.ts +++ b/src/cli/codex-log-guard-doctor.ts @@ -1,4 +1,8 @@ import { inspectCodexLogs, type CodexLogGuardInspection } from "../codex/log-guard/inspect"; +import { + getCodexLogGuardProtectionStatus, + type CodexLogGuardStatus, +} from "../codex/log-guard/protection"; function kib(bytes: number): string { return `${(bytes / 1024).toFixed(1)} KiB`; @@ -12,8 +16,31 @@ function fileMetadataLines(report: CodexLogGuardInspection): string[] { ]; } -export function formatCodexLogGuardDoctor(report: CodexLogGuardInspection): string[] { +type DoctorReport = CodexLogGuardInspection | CodexLogGuardStatus; + +function protectionLines(report: DoctorReport): string[] { + if (!("protection" in report)) return []; + const protection = report.protection; + switch (protection.state) { + case "active": + return [` ok protection active (${protection.desiredMode})`]; + case "off": + return [" -- protection off"]; + case "drifted": + return [ + ` WARN protection drifted (desired ${protection.desiredMode}; observed ${protection.observedMode})`, + " Action: ocx storage codex-logs repair", + ]; + case "unsupported": + return [" -- protection unavailable for this schema"]; + case "unknown": + return [" WARN protection state unknown; inspect reserved Log Guard triggers before changing mode"]; + } +} + +export function formatCodexLogGuardDoctor(report: DoctorReport): string[] { const lines = ["Codex diagnostic logs"]; + lines.push(...protectionLines(report)); if (report.schema.state === "unavailable") { lines.push(" -- inspection unavailable"); @@ -50,17 +77,26 @@ export function formatCodexLogGuardDoctor(report: CodexLogGuardInspection): stri } export interface CodexLogGuardDoctorDeps { - inspect?: () => CodexLogGuardInspection; + inspect?: () => DoctorReport; log?: (line: string) => void; } /** Observe-only doctor section. Inspection failures are reported without mutating or failing doctor. */ export function printCodexLogGuardDoctor(deps: CodexLogGuardDoctorDeps = {}): void { - const inspect = deps.inspect ?? inspectCodexLogs; + const inspect = deps.inspect ?? getCodexLogGuardProtectionStatus; const log = deps.log ?? console.log; try { for (const line of formatCodexLogGuardDoctor(inspect())) log(line); - } catch { + } catch (error) { + // Production can still fall back to PR 1's simpler inspector if the enriched + // protection lookup fails. An injected inspector is a test/caller boundary: + // never escape that boundary and touch the real Codex home behind its back. + if (!deps.inspect) { + try { + for (const line of formatCodexLogGuardDoctor(inspectCodexLogs())) log(line); + return; + } catch { /* report the original failure below */ } + } log("Codex diagnostic logs"); log(" -- inspection unavailable"); } diff --git a/src/cli/observe.ts b/src/cli/observe.ts index b6494d8924..c5864968cf 100644 --- a/src/cli/observe.ts +++ b/src/cli/observe.ts @@ -18,7 +18,7 @@ const USAGE = `Usage: ocx logs rebuild-index ocx logs index-status ocx observe usage [--range <7d|30d|all>] [--surface ] [--json] - ocx observe storage [codex-logs [status]] [--json] + ocx observe storage [codex-logs [status|protect|unprotect|repair] [--mode ]] [--json] ocx observe memory [--json] ocx observe debug [--json] ocx observe claude-inbound [--limit ] [--json] @@ -154,13 +154,32 @@ async function storage(argv: string[], deps: RuntimeApiDeps): Promise { } const args = argv.slice(1); - if (args[0] === "status") args.shift(); - else if (args[0] && !args[0].startsWith("-")) { - throw new CliUsageError(`unknown codex-logs action ${args[0]}`, USAGE); - } + const action = args[0] && !args[0].startsWith("-") ? args.shift()! : "status"; const wantsJson = takeFlag(args, "--json"); + const mode = takeOption(args, "--mode"); rejectArgs(args, USAGE); - const result = await runtimeRequest("/api/storage/codex-logs", {}, deps); + + let result: unknown; + if (action === "status") { + if (mode !== undefined) throw new CliUsageError("--mode is only valid with codex-logs protect", USAGE); + result = await runtimeRequest("/api/storage/codex-logs", {}, deps); + } else if (action === "protect") { + const requestedMode = mode ?? "compat"; + if (requestedMode !== "compat" && requestedMode !== "quiet") { + throw new CliUsageError("--mode must be compat or quiet", USAGE); + } + result = await runtimeRequest("/api/storage/codex-logs/protect", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: requestedMode }), + }, deps); + } else if (action === "unprotect" || action === "repair") { + if (mode !== undefined) throw new CliUsageError("--mode is only valid with codex-logs protect", USAGE); + result = await runtimeRequest(`/api/storage/codex-logs/${action}`, { method: "POST" }, deps); + } else { + throw new CliUsageError(`unknown codex-logs action ${action}`, USAGE); + } + printData(result, wantsJson, summaryLines(result)); } diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index a0dd0c79f3..60c778e6b3 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -75,6 +75,7 @@ export interface CodexAppServerProcess { export interface ProcessSnapshot { pid: number; commandLine: string; + executable?: string; uid?: number; owner?: string; startedAtMs?: number; @@ -223,13 +224,20 @@ export function codexAppServerProcessIdentity(proc: Pick(); + for (const raw of executableOutput.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(.+)$/.exec(raw); + if (!match) continue; + const pid = Number(match[1]); + const executable = match[2]?.trim() ?? ""; + if (Number.isSafeInteger(pid) && pid > 0 && executable) executableByPid.set(pid, executable); + } + for (const raw of commandOutput.split(/\r?\n/)) { const line = raw.trim(); if (!line) continue; if (uid !== undefined) { @@ -301,8 +318,8 @@ function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] { if (!match) continue; const pid = Number(match[1]); const commandLine = match[2]?.trim() ?? ""; - if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) continue; - out.push({ pid, commandLine, uid }); + if (!Number.isSafeInteger(pid) || pid <= 0 || !commandLine) continue; + out.push({ pid, commandLine, executable: executableByPid.get(pid), uid }); continue; } const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(line); @@ -310,8 +327,11 @@ function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] { const pid = Number(match[1]); const processUid = Number(match[2]); const commandLine = match[3]?.trim() ?? ""; - if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) continue; - out.push({ pid, commandLine, uid: Number.isSafeInteger(processUid) ? processUid : undefined }); + if (!Number.isSafeInteger(pid) || pid <= 0 || !commandLine) continue; + out.push({ + pid, commandLine, executable: executableByPid.get(pid), + uid: Number.isSafeInteger(processUid) ? processUid : undefined, + }); } return out; } @@ -414,7 +434,7 @@ export function listCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): C const matched: CodexAppServerProcess[] = []; for (const snapshot of snapshots) { if (seen.has(snapshot.pid)) continue; - if (!isCodexAppServerCommandLine(snapshot.commandLine)) continue; + if (!isCodexAppServerCommandLine(snapshot.commandLine, snapshot.executable)) continue; seen.add(snapshot.pid); matched.push({ pid: snapshot.pid, commandLine: snapshot.commandLine }); } @@ -626,7 +646,7 @@ export function collectCodexAppServerCatalogState( const seen = new Set(); for (const snapshot of snapshots) { if (seen.has(snapshot.pid)) continue; - if (!isCodexAppServerCommandLine(snapshot.commandLine)) continue; + if (!isCodexAppServerCommandLine(snapshot.commandLine, snapshot.executable)) continue; seen.add(snapshot.pid); processes.push({ pid: snapshot.pid, commandLine: snapshot.commandLine }); } diff --git a/src/codex/log-guard/lock.ts b/src/codex/log-guard/lock.ts new file mode 100644 index 0000000000..a3baf684c1 --- /dev/null +++ b/src/codex/log-guard/lock.ts @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import { chmodSync, lstatSync, mkdirSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; + +import { Database } from "bun:sqlite"; + +import { + normalizeTrustedDarwinSystemAlias, + sameLogGuardPathIdentity, +} from "./path-safety"; +import { isSqliteBusy } from "./sqlite-errors"; + +import { + CodexUserIdentityRefusal, + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../user-identity"; + +export type CodexLogGuardLockOutcome = + | { kind: "completed"; value: T } + | { kind: "unavailable"; reason: "busy" | "database" | "unsafe-path" }; + +export interface CodexLogGuardLockDeps { + /** Test seam. Production resolves a dedicated DB in the trusted user runtime root. */ + resolveDatabasePath?: (canonicalCodexHome: string, canonicalLogsDbPath: string) => string; +} + +function lockFileIsSafe(path: string, requireRealpath: boolean): boolean { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) return false; + if (requireRealpath && !sameLogGuardPathIdentity(realpathSync.native(path), path)) return false; + if (process.platform === "win32") return true; + const uid = process.getuid?.(); + return uid !== undefined && stat.uid === uid && (stat.mode & 0o777) === 0o600; +} + +export function codexLogGuardLockDigest( + canonicalCodexHome: string, + canonicalLogsDbPath: string, +): string { + const digestCodexHome = normalizeTrustedDarwinSystemAlias(canonicalCodexHome); + const digestLogsDbPath = normalizeTrustedDarwinSystemAlias(canonicalLogsDbPath); + return createHash("sha256") + .update(`${digestCodexHome.length}:${digestCodexHome}`) + .update(`${digestLogsDbPath.length}:${digestLogsDbPath}`) + .digest("hex"); +} + +function resolveLogGuardLockDatabase( + canonicalCodexHome: string, + canonicalLogsDbPath: string, +): string { + if (!isAbsolute(canonicalCodexHome) || !isAbsolute(canonicalLogsDbPath)) { + throw new CodexUserIdentityRefusal("Codex Log Guard lock keys must be absolute paths."); + } + + const identity = resolveEffectiveUserIdentity(); + // Use the native-coordinator resolver only to enter the already-audited, + // environment-independent per-user runtime root. L itself is a different + // database in a sibling directory and never acquires N or H. + const coordinatorPath = resolveCodexCoordinatorDatabasePath(identity, canonicalCodexHome); + const runtimeRoot = dirname(dirname(coordinatorPath)); + const locksDir = join(runtimeRoot, "log-guard-locks"); + mkdirSync(locksDir, { recursive: true, mode: 0o700 }); + const dirStat = lstatSync(locksDir); + if (!dirStat.isDirectory() || dirStat.isSymbolicLink() + || !sameLogGuardPathIdentity(realpathSync.native(locksDir), locksDir)) { + throw new CodexUserIdentityRefusal("Codex Log Guard lock directory is unsafe."); + } + if (process.platform !== "win32") { + const uid = process.getuid?.(); + if (uid === undefined || dirStat.uid !== uid || (dirStat.mode & 0o777) !== 0o700) { + throw new CodexUserIdentityRefusal("Codex Log Guard lock directory is not private to the effective user."); + } + } + + return join(locksDir, `${codexLogGuardLockDigest(canonicalCodexHome, canonicalLogsDbPath)}.sqlite`); +} + +export function withCodexLogGuardLock( + canonicalCodexHome: string, + canonicalLogsDbPath: string, + work: () => T, + deps: CodexLogGuardLockDeps = {}, +): CodexLogGuardLockOutcome { + let databasePath: string; + try { + databasePath = deps.resolveDatabasePath?.(canonicalCodexHome, canonicalLogsDbPath) + ?? resolveLogGuardLockDatabase(canonicalCodexHome, canonicalLogsDbPath); + } catch (error) { + if (error instanceof CodexUserIdentityRefusal) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + return { kind: "unavailable", reason: "database" }; + } + + let database: Database | undefined; + let transactionOpen = false; + try { + let absent = false; + try { + if (!lockFileIsSafe(databasePath, false)) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") throw error; + absent = true; + } + + database = new Database(databasePath, { create: true }); + if (absent) { + try { chmodSync(databasePath, 0o600); } catch { /* Windows permissions are enforced by the trusted runtime root. */ } + } + if (!lockFileIsSafe(databasePath, true)) { + return { kind: "unavailable", reason: "unsafe-path" }; + } + + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + const value = work(); + database.exec("COMMIT"); + transactionOpen = false; + return { kind: "completed", value }; + } catch (error) { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close releases it */ } + } + if (isSqliteBusy(error)) return { kind: "unavailable", reason: "busy" }; + throw error; + } finally { + try { database?.close(); } catch { /* acquisition already settled */ } + } +} diff --git a/src/codex/log-guard/path-safety.ts b/src/codex/log-guard/path-safety.ts new file mode 100644 index 0000000000..e1f0ee0736 --- /dev/null +++ b/src/codex/log-guard/path-safety.ts @@ -0,0 +1,39 @@ +import { realpathSync } from "node:fs"; +import { resolve, sep } from "node:path"; + +import { samePathIdentity } from "../user-identity"; + +const TRUSTED_DARWIN_SYSTEM_ALIASES = [ + { alias: "/var", canonical: "/private/var" }, + { alias: "/tmp", canonical: "/private/tmp" }, +] as const; + +export function normalizeTrustedDarwinSystemAlias(path: string): string { + const requested = resolve(path); + if (process.platform !== "darwin") return requested; + + for (const entry of TRUSTED_DARWIN_SYSTEM_ALIASES) { + if (requested !== entry.alias && !requested.startsWith(`${entry.alias}${sep}`)) continue; + + let actualAliasTarget: string; + try { + actualAliasTarget = realpathSync.native(entry.alias); + } catch { + // If the platform alias is absent or unreadable, keep the strict spelling check. + return requested; + } + if (!samePathIdentity(actualAliasTarget, entry.canonical, "darwin")) return requested; + return `${entry.canonical}${requested.slice(entry.alias.length)}`; + } + + return requested; +} + +/** + * Compare a canonical realpath with a requested Log Guard path without treating + * macOS's OS-owned /var and /tmp aliases as user-controlled redirections. + * Arbitrary ancestor symlinks remain refused. + */ +export function sameLogGuardPathIdentity(realPath: string, requestedPath: string): boolean { + return samePathIdentity(realPath, normalizeTrustedDarwinSystemAlias(requestedPath)); +} diff --git a/src/codex/log-guard/policy.ts b/src/codex/log-guard/policy.ts new file mode 100644 index 0000000000..5fc149835b --- /dev/null +++ b/src/codex/log-guard/policy.ts @@ -0,0 +1,44 @@ +import { loadConfig, saveConfigPreservingClaudeCode } from "../../config"; +import type { OcxConfig } from "../../types"; + +export type CodexLogGuardMode = "off" | "compat" | "quiet"; + +type ConfigWithLogGuard = OcxConfig & { + codexLogGuard?: { + mode?: unknown; + [key: string]: unknown; + }; +}; + +export interface CodexLogGuardPolicyDeps { + load?: () => OcxConfig; + save?: (config: OcxConfig) => void; +} + +export function readCodexLogGuardMode(deps: CodexLogGuardPolicyDeps = {}): CodexLogGuardMode { + const config = (deps.load ?? loadConfig)() as ConfigWithLogGuard; + const mode = config.codexLogGuard?.mode; + return mode === "compat" || mode === "quiet" ? mode : "off"; +} + +/** + * Persist user intent separately from Codex's logs database. + * + * A Codex migration may rebuild the `logs` table and thereby remove our + * trigger. Keeping intent in OpenCodex config makes that observable as drift + * rather than silently treating protection as disabled. `off` is explicit so + * a stale unknown value cannot reactivate protection later. + */ +export function writeCodexLogGuardMode( + mode: CodexLogGuardMode, + deps: CodexLogGuardPolicyDeps = {}, +): void { + const load = deps.load ?? loadConfig; + const save = deps.save ?? saveConfigPreservingClaudeCode; + const config = load() as ConfigWithLogGuard; + const current = config.codexLogGuard && typeof config.codexLogGuard === "object" + ? config.codexLogGuard + : {}; + config.codexLogGuard = { ...current, mode }; + save(config); +} diff --git a/src/codex/log-guard/processes.ts b/src/codex/log-guard/processes.ts new file mode 100644 index 0000000000..3d353c53ee --- /dev/null +++ b/src/codex/log-guard/processes.ts @@ -0,0 +1,205 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; + +import { + listWindowsSnapshots, + tokenizeCommandLine, + type ProcessSnapshot, +} from "../app-server-processes"; + +export interface CodexWriterProcess { + pid: number; + commandLine: string; +} + +export type CodexWriterProcessCheck = + | { state: "ok"; processes: CodexWriterProcess[] } + | { state: "unknown"; reason: "enumeration_failed" }; + +export interface CodexWriterProcessIo { + platform?: NodeJS.Platform; + getuid?: () => number | undefined; + listSnapshots?: () => ProcessSnapshot[]; +} + +const TARGET_TRIPLE = /^[a-z0-9_]+-[a-z0-9_]+-[a-z0-9_]+(?:-[a-z0-9_]+)?$/i; + +function basename(token: string): string { + return token.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? ""; +} + +function isOfficialCodexExecutable(token: string): boolean { + const base = basename(token); + if (base === "codex" || base === "codex.exe" || base === "codex.cmd") return true; + const withoutSuffix = base.replace(/\.(?:exe|cmd)$/i, ""); + if (!withoutSuffix.startsWith("codex-")) return false; + return TARGET_TRIPLE.test(withoutSuffix.slice("codex-".length)); +} + +function isCodeModeHostExecutable(token: string): boolean { + const base = basename(token); + return base === "codex-code-mode-host" || base === "codex-code-mode-host.exe"; +} + +function isInterpreterExecutable(token: string): boolean { + const base = basename(token); + return base === "node" || base === "node.exe" + || base === "bun" || base === "bun.exe" + || base === "deno" || base === "deno.exe"; +} + +function looksLikePathPrefix(token: string): boolean { + return token.startsWith("/") || token.startsWith("./") || token.startsWith("../") + || token.startsWith("~") || /^[a-z]:[\\/]/i.test(token); +} + +function flattenedPathPrefix( + commandLine: string, + predicate: (candidate: string) => boolean, +): { executable: string; remainder: string } | null { + const parts = commandLine.trim().split(/\s+/).filter(Boolean); + for (let end = parts.length; end >= 1; end -= 1) { + const executable = parts.slice(0, end).join(" "); + if (!predicate(executable)) continue; + return { executable, remainder: parts.slice(end).join(" ") }; + } + return null; +} + +function interpreterHostWithFlattenedPath(commandLine: string, tokens: readonly string[]): boolean { + if (tokens.length < 2 || !isInterpreterExecutable(tokens[0]!)) return false; + const trimmed = commandLine.trim(); + const firstWhitespace = trimmed.search(/\s/); + if (firstWhitespace < 0) return false; + const remainder = trimmed.slice(firstWhitespace).trim(); + return flattenedPathPrefix(remainder, isCodeModeHostExecutable) !== null; +} + +/** + * Match official Codex writer executables at argv0 plus the repository's + * established interpreter-entrypoint form for code-mode-host. + * + * OS process listings can flatten argv into a display string. When an unquoted + * executable path contains spaces, recover only a leading path-shaped executable + * prefix; arbitrary later argv tokens still never turn a process into Codex. + */ +export function isCodexWriterCommandLine(commandLine: string, executable?: string): boolean { + if (executable && (isOfficialCodexExecutable(executable) || isCodeModeHostExecutable(executable))) return true; + const tokens = tokenizeCommandLine(commandLine.trim()); + if (tokens.length === 0) return false; + if (isOfficialCodexExecutable(tokens[0]!) || isCodeModeHostExecutable(tokens[0]!)) return true; + return tokens.length > 1 + && isInterpreterExecutable(tokens[0]!) + && isCodeModeHostExecutable(tokens[1]!); +} + +function statusUid(status: string): number | undefined { + const match = /^Uid:\s+(\d+)/m.exec(status); + if (!match) return undefined; + const value = Number(match[1]); + return Number.isSafeInteger(value) ? value : undefined; +} + +function listLinuxSnapshots(uid: number | undefined): ProcessSnapshot[] { + if (!existsSync("/proc")) throw new Error("procfs_unavailable"); + const rows: ProcessSnapshot[] = []; + for (const entry of readdirSync("/proc")) { + if (!/^\d+$/.test(entry)) continue; + const pid = Number(entry); + if (!Number.isSafeInteger(pid) || pid <= 0) continue; + try { + const procUid = statusUid(readFileSync(`/proc/${pid}/status`, "utf8")); + if (uid !== undefined && procUid !== undefined && procUid !== uid) continue; + const argv = readFileSync(`/proc/${pid}/cmdline`) + .toString("utf8") + .split("\0") + .filter(Boolean); + const commandLine = argv.join(" ").trim(); + if (commandLine) rows.push({ pid, commandLine, executable: argv[0], uid: procUid }); + } catch { + // A process disappearing mid-enumeration is normal. A top-level procfs + // failure is handled before the loop and fails closed. + } + } + return rows; +} + +function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] { + const commandOutput = uid !== undefined + ? execFileSync("/bin/ps", ["-u", String(uid), "-o", "pid=,command="], { + encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, + }) + : execFileSync("/bin/ps", ["-axo", "pid=,uid=,command="], { + encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, + }); + const executableOutput = uid !== undefined + ? execFileSync("/bin/ps", ["-u", String(uid), "-o", "pid=,comm="], { + encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, + }) + : execFileSync("/bin/ps", ["-axo", "pid=,comm="], { + encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, + }); + const executableByPid = new Map(); + for (const raw of executableOutput.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(.+)$/.exec(raw); + if (!match) continue; + const pid = Number(match[1]); + const executable = match[2]?.trim() ?? ""; + if (Number.isSafeInteger(pid) && pid > 0 && executable) executableByPid.set(pid, executable); + } + const rows: ProcessSnapshot[] = []; + for (const raw of commandOutput.split(/\r?\n/)) { + const line = raw.trim(); + if (!line) continue; + const match = uid !== undefined + ? /^(\d+)\s+(.*)$/.exec(line) + : /^(\d+)\s+(\d+)\s+(.*)$/.exec(line); + if (!match) continue; + const pid = Number(match[1]); + const commandLine = (uid !== undefined ? match[2] : match[3])?.trim() ?? ""; + if (!Number.isSafeInteger(pid) || pid <= 0 || !commandLine) continue; + rows.push({ + pid, commandLine, executable: executableByPid.get(pid), + uid: uid ?? (Number.isSafeInteger(Number(match[2])) ? Number(match[2]) : undefined), + }); + } + return rows; +} + +function effectiveUid(getuid?: () => number | undefined): number | undefined { + try { + return getuid ? getuid() : process.getuid?.(); + } catch { + return undefined; + } +} + +function defaultSnapshots(platform: NodeJS.Platform, uid: number | undefined): ProcessSnapshot[] { + if (platform === "win32") return listWindowsSnapshots(); + if (platform === "darwin") return listDarwinSnapshots(uid); + if (platform === "linux") return listLinuxSnapshots(uid); + throw new Error("unsupported_process_enumeration_platform"); +} + +export function listRunningCodexProcesses(io: CodexWriterProcessIo = {}): CodexWriterProcessCheck { + const platform = io.platform ?? process.platform; + let snapshots: ProcessSnapshot[]; + try { + snapshots = io.listSnapshots?.() ?? defaultSnapshots(platform, effectiveUid(io.getuid)); + } catch { + return { state: "unknown", reason: "enumeration_failed" }; + } + + const byPid = new Map(); + for (const snapshot of snapshots) { + if (!Number.isSafeInteger(snapshot.pid) || snapshot.pid <= 0) continue; + if (!isCodexWriterCommandLine(snapshot.commandLine, snapshot.executable)) continue; + if (!byPid.has(snapshot.pid)) { + byPid.set(snapshot.pid, { pid: snapshot.pid, commandLine: snapshot.commandLine }); + } + } + return { + state: "ok", + processes: [...byPid.values()].sort((a, b) => a.pid - b.pid), + }; +} diff --git a/src/codex/log-guard/protection.ts b/src/codex/log-guard/protection.ts new file mode 100644 index 0000000000..55723886e7 --- /dev/null +++ b/src/codex/log-guard/protection.ts @@ -0,0 +1,431 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { Database, constants as sqliteConstants } from "bun:sqlite"; + +import { getCodexHome, resolveCodexLogsDbPath } from "../paths"; +import { inspectCodexLogs, type CodexLogGuardInspection } from "./inspect"; +import { withCodexLogGuardLock, type CodexLogGuardLockOutcome } from "./lock"; +import { sameLogGuardPathIdentity } from "./path-safety"; +import { isSqliteBusy } from "./sqlite-errors"; +import { + readCodexLogGuardMode, + writeCodexLogGuardMode, + type CodexLogGuardMode, +} from "./policy"; +import { + listRunningCodexProcesses, + type CodexWriterProcessCheck, +} from "./processes"; + +export { type CodexLogGuardMode } from "./policy"; + +const IMMUTABLE_READONLY_FLAGS = sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI; +const COMPAT_TRIGGER = "opencodex_log_guard_compat_v1"; +const QUIET_TRIGGER = "opencodex_log_guard_quiet_v1"; +const OWNED_TRIGGER_NAMES = [COMPAT_TRIGGER, QUIET_TRIGGER] as const; + +const CURRENT_LOG_COLUMNS = [ + "id", + "ts", + "ts_nanos", + "level", + "target", + "feedback_log_body", + "module_path", + "file", + "line", + "thread_id", + "process_uuid", + "estimated_bytes", +] as const; + +/** + * Versioned compatibility policy pinned to the current upstream Codex persistent + * log filters researched for Log Guard v1. It intentionally preserves unrelated + * TRACE rows rather than assuming all TRACE diagnostics are disposable. + */ +const COMPAT_TRIGGER_SQL = `CREATE TRIGGER ${COMPAT_TRIGGER} +BEFORE INSERT ON logs +WHEN + NEW.target = 'log' + OR NEW.target = 'codex_otel.log_only' + OR NEW.target = 'codex_otel.trace_safe' + OR NEW.target = 'codex_api::responses_websocket_timing' + OR NEW.target = 'codex_core::post_sampling_token_estimate' + OR (NEW.target = 'hyper_util' AND upper(NEW.level) IN ('TRACE', 'DEBUG', 'INFO')) + OR (NEW.target IN ('codex_rmcp_client', 'rmcp') AND upper(NEW.level) IN ('TRACE', 'DEBUG')) + OR (NEW.target IN ( + 'codex_http_client::transport', + 'codex_api::sse', + 'codex_tui::streaming::controller', + 'codex_tui::streaming::table_holdback' + ) AND upper(NEW.level) = 'TRACE') + OR (NEW.target = 'opentelemetry_sdk' AND upper(NEW.level) IN ('TRACE', 'DEBUG')) +BEGIN + SELECT RAISE(IGNORE); +END`; + +const QUIET_TRIGGER_SQL = `CREATE TRIGGER ${QUIET_TRIGGER} +BEFORE INSERT ON logs +WHEN upper(NEW.level) = 'TRACE' +BEGIN + SELECT RAISE(IGNORE); +END`; + +const SQL_BY_MODE: Record, string> = { + compat: COMPAT_TRIGGER_SQL, + quiet: QUIET_TRIGGER_SQL, +}; + +export type CodexLogGuardObservedMode = CodexLogGuardMode | "collision"; +export type CodexLogGuardProtectionState = "off" | "active" | "drifted" | "unsupported" | "unknown"; + +export interface CodexLogGuardProtectionSummary { + desiredMode: CodexLogGuardMode; + observedMode: CodexLogGuardObservedMode; + state: CodexLogGuardProtectionState; +} + +export type CodexLogGuardStatus = CodexLogGuardInspection & { + protection: CodexLogGuardProtectionSummary; +}; + +export type CodexLogGuardMutationError = + | "unsupported_schema" + | "codex_running" + | "process_enumeration_failed" + | "trigger_collision" + | "unsafe_path" + | "busy" + | "database_error" + | "config_write_failed"; + +export type CodexLogGuardMutationResult = + | { ok: true; status: CodexLogGuardStatus } + | { ok: false; error: CodexLogGuardMutationError }; + +export interface CodexLogGuardProtectionDeps { + codexHome?: string; + processCheck?: () => CodexWriterProcessCheck; + readDesiredMode?: () => CodexLogGuardMode; + writeDesiredMode?: (mode: CodexLogGuardMode) => void; + withLock?: ( + canonicalCodexHome: string, + canonicalLogsDbPath: string, + work: () => T, + ) => CodexLogGuardLockOutcome; +} + +interface TriggerRow { + name: string; + sql: string | null; +} +interface ColumnRow { name: string } +interface OwnedTriggerSnapshot { name: string; sql: string } + +type LockedMutationResult = + | { ok: true } + | { ok: false; error: CodexLogGuardMutationError }; + +function normalizeSql(sql: string | null | undefined): string { + return (sql ?? "").trim().replace(/;\s*$/, "").replace(/\s+/g, " "); +} + +function expectedSql(mode: Exclude): string { + return normalizeSql(SQL_BY_MODE[mode]); +} + +function ownedModeForRow(row: TriggerRow): Exclude | null { + if (row.name === COMPAT_TRIGGER && normalizeSql(row.sql) === expectedSql("compat")) return "compat"; + if (row.name === QUIET_TRIGGER && normalizeSql(row.sql) === expectedSql("quiet")) return "quiet"; + return null; +} + +function queryReservedTriggers(db: Database): TriggerRow[] { + const placeholders = OWNED_TRIGGER_NAMES.map(() => "?").join(", "); + return db.query( + `SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND name IN (${placeholders}) ORDER BY name`, + ).all(...OWNED_TRIGGER_NAMES); +} + +function observeTriggers(db: Database): CodexLogGuardObservedMode { + const rows = queryReservedTriggers(db); + if (rows.length === 0) return "off"; + const modes = rows.map(ownedModeForRow); + if (modes.some(mode => mode === null)) return "collision"; + const unique = new Set(modes); + return unique.size === 1 && rows.length === 1 ? modes[0]! : "collision"; +} + +function exactCurrentSchema(db: Database): boolean { + const columns = db.query("PRAGMA table_info(logs)").all().map(row => row.name).sort(); + const expected = [...CURRENT_LOG_COLUMNS].sort(); + return columns.length === expected.length && columns.every((value, index) => value === expected[index]); +} + +/** + * Read trigger metadata with the same immutable/checkpointed semantics as PR 1 + * diagnostics. A status GET must never participate in Codex's SQLite WAL/SHM + * protocol or materialise sidecars merely to report protection state. + */ +function openReadOnly(databasePath: string): Database { + const uri = `${pathToFileURL(databasePath).href}?immutable=1`; + return new Database(uri, IMMUTABLE_READONLY_FLAGS); +} + +function openReadWrite(databasePath: string): Database { + // READWRITE without CREATE: a missing/moved canonical DB is a refusal, not a + // reason for OpenCodex to materialise a new foreign database. + return new Database(databasePath, sqliteConstants.SQLITE_OPEN_READWRITE); +} + +function databasePathIsSafe(databasePath: string): boolean { + try { + const stat = lstatSync(databasePath); + if (!stat.isFile() || stat.isSymbolicLink()) return false; + return sameLogGuardPathIdentity(realpathSync.native(databasePath), databasePath); + } catch { + return false; + } +} + +function protectionSummary( + inspection: CodexLogGuardInspection, + desiredMode: CodexLogGuardMode, + observedMode: CodexLogGuardObservedMode, +): CodexLogGuardProtectionSummary { + if (inspection.capabilities.protection.state !== "supported") { + return { desiredMode, observedMode, state: "unsupported" }; + } + if (observedMode === "collision") return { desiredMode, observedMode, state: "unknown" }; + if (desiredMode === "off" && observedMode === "off") { + return { desiredMode, observedMode, state: "off" }; + } + if (desiredMode !== "off" && desiredMode === observedMode) { + return { desiredMode, observedMode, state: "active" }; + } + return { desiredMode, observedMode, state: "drifted" }; +} + +function inspectionDeps(deps: CodexLogGuardProtectionDeps): { codexHome?: string } { + return deps.codexHome ? { codexHome: deps.codexHome } : {}; +} + +export function getCodexLogGuardProtectionStatus( + deps: CodexLogGuardProtectionDeps = {}, +): CodexLogGuardStatus { + const codexHome = deps.codexHome ?? getCodexHome(); + const inspection = inspectCodexLogs({ codexHome }); + const databasePath = resolveCodexLogsDbPath({ codexHome }); + const desiredMode = (deps.readDesiredMode ?? readCodexLogGuardMode)(); + let observedMode: CodexLogGuardObservedMode = inspection.schema.state === "compatible" ? "collision" : "off"; + + if (inspection.schema.state === "compatible" && databasePathIsSafe(databasePath)) { + try { + const db = openReadOnly(databasePath); + try { observedMode = observeTriggers(db); } + finally { db.close(); } + } catch { + observedMode = "collision"; + } + } + + return { + ...inspection, + protection: protectionSummary(inspection, desiredMode, observedMode), + }; +} + +function successfulMutationStatus( + codexHome: string, + mode: CodexLogGuardMode, +): CodexLogGuardStatus { + const inspection = inspectCodexLogs({ codexHome }); + const state: CodexLogGuardProtectionState = inspection.capabilities.protection.state === "supported" + ? (mode === "off" ? "off" : "active") + : "unsupported"; + return { + ...inspection, + protection: { desiredMode: mode, observedMode: mode, state }, + }; +} + +function processRefusal(check: CodexWriterProcessCheck): CodexLogGuardMutationError | null { + if (check.state === "unknown") return "process_enumeration_failed"; + if (check.processes.length > 0) return "codex_running"; + return null; +} + +function mutateOwnedTrigger( + databasePath: string, + mode: CodexLogGuardMode, +): { ok: true; previousTriggers: readonly OwnedTriggerSnapshot[] } | { ok: false; error: CodexLogGuardMutationError } { + let db: Database | undefined; + let transactionOpen = false; + try { + if (!databasePathIsSafe(databasePath)) return { ok: false, error: "unsafe_path" }; + db = openReadWrite(databasePath); + db.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + if (!exactCurrentSchema(db)) { + db.exec("ROLLBACK"); + transactionOpen = false; + return { ok: false, error: "unsupported_schema" }; + } + + const rows = queryReservedTriggers(db); + const modes = rows.map(ownedModeForRow); + if (modes.some(item => item === null)) { + db.exec("ROLLBACK"); + transactionOpen = false; + return { ok: false, error: "trigger_collision" }; + } + const previousTriggers: OwnedTriggerSnapshot[] = rows.map(row => ({ + name: row.name, + sql: row.sql!, + })); + + for (const row of rows) { + // Name is from our fixed allow-list; never interpolate arbitrary sqlite_master data. + db.exec(`DROP TRIGGER ${row.name}`); + } + if (mode !== "off") db.exec(SQL_BY_MODE[mode]); + + const observed = observeTriggers(db); + if (observed !== mode) throw new Error("log_guard_trigger_verification_failed"); + db.exec("COMMIT"); + transactionOpen = false; + return { ok: true, previousTriggers }; + } catch (error) { + if (transactionOpen) { + try { db?.exec("ROLLBACK"); } catch { /* close releases the transaction */ } + } + if (isSqliteBusy(error)) return { ok: false, error: "busy" }; + return { ok: false, error: "database_error" }; + } finally { + try { db?.close(); } catch { /* mutation already settled */ } + } +} + +function restoreOwnedTriggers(databasePath: string, previousTriggers: readonly OwnedTriggerSnapshot[]): void { + // Best-effort compensation only. Failure is deliberately not hidden by + // claiming success; the caller returns config_write_failed and status will + // expose any remaining drift on the next read. + let db: Database | undefined; + let transactionOpen = false; + try { + if (!databasePathIsSafe(databasePath)) return; + db = openReadWrite(databasePath); + db.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + if (!exactCurrentSchema(db)) { + db.exec("ROLLBACK"); + transactionOpen = false; + return; + } + + const current = queryReservedTriggers(db); + if (current.some(row => ownedModeForRow(row) === null)) { + db.exec("ROLLBACK"); + transactionOpen = false; + return; + } + for (const row of current) db.exec(`DROP TRIGGER ${row.name}`); + + for (const trigger of previousTriggers) { + if (ownedModeForRow(trigger) === null) throw new Error("invalid_owned_trigger_snapshot"); + db.exec(trigger.sql); + } + + const restored = queryReservedTriggers(db); + const expected = new Map(previousTriggers.map(row => [row.name, normalizeSql(row.sql)])); + if (restored.length !== previousTriggers.length + || restored.some(row => expected.get(row.name) !== normalizeSql(row.sql))) { + throw new Error("log_guard_trigger_restore_verification_failed"); + } + db.exec("COMMIT"); + transactionOpen = false; + } catch { + if (transactionOpen) { + try { db?.exec("ROLLBACK"); } catch { /* close releases the transaction */ } + } + } finally { + try { db?.close(); } catch { /* compensation already settled */ } + } +} + +function performMutation( + requestedMode: CodexLogGuardMode, + deps: CodexLogGuardProtectionDeps, +): CodexLogGuardMutationResult { + const codexHome = deps.codexHome ?? getCodexHome(); + const inspection = inspectCodexLogs({ codexHome }); + const databasePath = resolveCodexLogsDbPath({ codexHome }); + if (inspection.capabilities.protection.state !== "supported") { + return { ok: false, error: "unsupported_schema" }; + } + if (!databasePathIsSafe(databasePath)) return { ok: false, error: "unsafe_path" }; + + const checkProcesses = deps.processCheck ?? listRunningCodexProcesses; + const firstRefusal = processRefusal(checkProcesses()); + if (firstRefusal) return { ok: false, error: firstRefusal }; + + const withLock = deps.withLock ?? withCodexLogGuardLock; + const writeDesired = deps.writeDesiredMode ?? writeCodexLogGuardMode; + let locked: CodexLogGuardLockOutcome; + try { + locked = withLock(codexHome, databasePath, () => { + // Recheck after acquiring L so a Codex process that starts during lock + // acquisition cannot race the foreign-schema mutation. + const secondRefusal = processRefusal(checkProcesses()); + if (secondRefusal) return { ok: false, error: secondRefusal }; + + const mutation = mutateOwnedTrigger(databasePath, requestedMode); + if (!mutation.ok) return mutation; + + // Desired state belongs to the same logical transition as the trigger. + // Keep L held through this write so another OpenCodex process cannot + // interleave a different mode between the DB commit and config commit. + try { + writeDesired(requestedMode); + } catch { + restoreOwnedTriggers(databasePath, mutation.previousTriggers); + return { ok: false, error: "config_write_failed" as const }; + } + return { ok: true }; + }); + } catch { + return { ok: false, error: "database_error" }; + } + if (locked.kind === "unavailable") { + return { + ok: false, + error: locked.reason === "busy" + ? "busy" + : locked.reason === "unsafe-path" ? "unsafe_path" : "database_error", + }; + } + if (!locked.value.ok) return locked.value; + + return { ok: true, status: successfulMutationStatus(codexHome, requestedMode) }; +} + +export function protectCodexLogs( + mode: Exclude, + deps: CodexLogGuardProtectionDeps = {}, +): CodexLogGuardMutationResult { + return performMutation(mode, deps); +} + +export function unprotectCodexLogs( + deps: CodexLogGuardProtectionDeps = {}, +): CodexLogGuardMutationResult { + return performMutation("off", deps); +} + +export function repairCodexLogGuardProtection( + deps: CodexLogGuardProtectionDeps = {}, +): CodexLogGuardMutationResult { + const desired = (deps.readDesiredMode ?? readCodexLogGuardMode)(); + return performMutation(desired, deps); +} diff --git a/src/codex/log-guard/sqlite-errors.ts b/src/codex/log-guard/sqlite-errors.ts new file mode 100644 index 0000000000..590965f49d --- /dev/null +++ b/src/codex/log-guard/sqlite-errors.ts @@ -0,0 +1,9 @@ +/** Classify SQLite lock contention consistently across Log Guard mutation paths. */ +export function isSqliteBusy(error: unknown): boolean { + const code = error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + const message = error instanceof Error ? error.message : String(error); + return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message); +} diff --git a/src/server/management/context.ts b/src/server/management/context.ts index d049df2dc2..190d7b9b45 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -1,5 +1,6 @@ import type { OcxConfig } from "../../types"; import type { NativeProfileApiDeps } from "../../codex/native-profile-api"; +import type { CodexLogGuardProtectionDeps } from "../../codex/log-guard/protection"; import type { StartupHealth } from "../../codex/autostart-health"; import type { StartupInstallAction } from "../startup-action-control"; import type { ManagementPrincipal } from "../management-auth"; @@ -70,6 +71,13 @@ export interface ManagementApiDeps { performRestart: typeof performCodexRestart; }; nativeProfileApi?: NativeProfileApiDeps; + /** + * Log Guard mutation seam. Production leaves this unset and therefore uses the + * owner-verified process enumerator, trusted L namespace and real config store. + * Route tests inject all three so they cannot depend on local Codex processes + * or create lock/config state outside the fixture. + */ + codexLogGuardProtectionDeps?: CodexLogGuardProtectionDeps; } diff --git a/src/server/management/storage-log-guard-routes.ts b/src/server/management/storage-log-guard-routes.ts index 3d22d608b7..57efd10aae 100644 --- a/src/server/management/storage-log-guard-routes.ts +++ b/src/server/management/storage-log-guard-routes.ts @@ -1,23 +1,80 @@ import { resolveCodexHomeDir } from "../../codex/home"; -import { inspectCodexLogs, type CodexLogGuardInspection } from "../../codex/log-guard/inspect"; +import { + getCodexLogGuardProtectionStatus, + protectCodexLogs, + repairCodexLogGuardProtection, + unprotectCodexLogs, + type CodexLogGuardMutationResult, + type CodexLogGuardStatus, +} from "../../codex/log-guard/protection"; import { scanStorage } from "../../storage/scanner"; import { jsonResponse } from "../auth-cors"; +import { + managementBodyTooLargeResponse, + readManagementJsonBody, +} from "./body"; import type { ManagementContext } from "./context"; const INSPECTION_FAILED_MESSAGE = "Codex log inspection failed"; -function inspectionUnavailable(report: CodexLogGuardInspection): boolean { +function inspectionUnavailable(report: CodexLogGuardStatus): boolean { return report.schema.state === "unavailable"; } -/** Read-only Codex Log Guard management surface. Mutation endpoints arrive in PR 2/3. */ +function mutationStatus(result: CodexLogGuardMutationResult): number { + if (result.ok) return 200; + switch (result.error) { + case "process_enumeration_failed": + return 503; + case "codex_running": + case "busy": + case "unsupported_schema": + case "trigger_collision": + case "unsafe_path": + return 409; + case "database_error": + case "config_write_failed": + return 500; + } +} + +function mutationResponse( + result: CodexLogGuardMutationResult, + ctx: ManagementContext, +): Response { + return result.ok + ? jsonResponse(result.status, 200, ctx.req, ctx.config) + : jsonResponse({ error: result.error }, mutationStatus(result), ctx.req, ctx.config); +} + +async function readProtectMode(ctx: ManagementContext): Promise<"compat" | "quiet" | Response> { + let body: unknown; + try { + body = await readManagementJsonBody(ctx.req); + } catch (error) { + const tooLarge = managementBodyTooLargeResponse(error, ctx.req, ctx.config); + if (tooLarge) return tooLarge; + return jsonResponse({ error: "invalid_request" }, 400, ctx.req, ctx.config); + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return jsonResponse({ error: "invalid_request" }, 400, ctx.req, ctx.config); + } + const mode = (body as Record).mode; + if (mode !== "compat" && mode !== "quiet") { + return jsonResponse({ error: "invalid_mode" }, 400, ctx.req, ctx.config); + } + return mode; +} + +/** Codex Log Guard diagnostics and explicit, opt-in protection mutations. */ export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promise { - const { req, url, config } = ctx; - if (req.method !== "GET") return null; + const { req, url, config, deps } = ctx; + const protectionDeps = deps.codexLogGuardProtectionDeps; if (url.pathname === "/api/storage/codex-logs") { + if (req.method !== "GET") return null; try { - const report = inspectCodexLogs(); + const report = getCodexLogGuardProtectionStatus(protectionDeps); if (inspectionUnavailable(report)) { return jsonResponse({ error: "inspect_failed", message: INSPECTION_FAILED_MESSAGE }, 500, req, config); } @@ -30,7 +87,24 @@ export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promi } } - if (url.pathname !== "/api/storage") return null; + if (url.pathname === "/api/storage/codex-logs/protect") { + if (req.method !== "POST") return null; + const mode = await readProtectMode(ctx); + if (mode instanceof Response) return mode; + return mutationResponse(protectCodexLogs(mode, protectionDeps), ctx); + } + + if (url.pathname === "/api/storage/codex-logs/unprotect") { + if (req.method !== "POST") return null; + return mutationResponse(unprotectCodexLogs(protectionDeps), ctx); + } + + if (url.pathname === "/api/storage/codex-logs/repair") { + if (req.method !== "POST") return null; + return mutationResponse(repairCodexLogGuardProtection(protectionDeps), ctx); + } + + if (url.pathname !== "/api/storage" || req.method !== "GET") return null; // Keep the existing CODEX_HOME scan as the primary storage contract. The Log Guard // report is attached separately so an external sqlite_home is visible without being @@ -47,7 +121,7 @@ export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promi error: "scan_failed", }; try { - const report = inspectCodexLogs(); + const report = getCodexLogGuardProtectionStatus(protectionDeps); return inspectionUnavailable(report) ? jsonResponse({ ...fallback, codexLogs: null, codexLogsError: "inspect_failed" }, 200, req, config) : jsonResponse({ ...fallback, codexLogs: report }, 200, req, config); @@ -57,7 +131,7 @@ export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promi } try { - const report = inspectCodexLogs(); + const report = getCodexLogGuardProtectionStatus(protectionDeps); return inspectionUnavailable(report) ? jsonResponse({ ...storage, codexLogs: null, codexLogsError: "inspect_failed" }, 200, req, config) : jsonResponse({ ...storage, codexLogs: report }, 200, req, config); diff --git a/tests/api-codex-log-guard-protection.test.ts b/tests/api-codex-log-guard-protection.test.ts new file mode 100644 index 0000000000..336410821c --- /dev/null +++ b/tests/api-codex-log-guard-protection.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { CodexLogGuardMode, CodexLogGuardProtectionDeps } from "../src/codex/log-guard/protection"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { ManagementRequest } from "./helpers/management-auth"; + +const roots: string[] = []; +const originalCodexHome = process.env.CODEX_HOME; +const originalOpenCodexHome = process.env.OPENCODEX_HOME; + +function createLogsDb(path: string): void { + const db = new Database(path); + db.exec(` + 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 + ); + CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_thread_id ON logs(thread_id); + CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_process_uuid_threadless_ts + ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) + WHERE thread_id IS NULL; + `); + db.close(); +} + +function fixture(): { databasePath: string; protectionDeps: CodexLogGuardProtectionDeps } { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-api-protect-")); + roots.push(root); + const codexHome = join(root, "codex-home"); + const ocxHome = join(root, "ocx-home"); + mkdirSync(codexHome); + mkdirSync(ocxHome); + writeFileSync(join(codexHome, "config.toml"), ""); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + port: 0, + defaultProvider: "openai", + providers: {}, + })); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = ocxHome; + const databasePath = join(codexHome, "logs_2.sqlite"); + createLogsDb(databasePath); + + let desiredMode: CodexLogGuardMode = "off"; + const protectionDeps: CodexLogGuardProtectionDeps = { + codexHome, + processCheck: () => ({ state: "ok", processes: [] }), + readDesiredMode: () => desiredMode, + writeDesiredMode: mode => { desiredMode = mode; }, + withLock: (_home: string, _database: string, work: () => T) => ({ + kind: "completed", + value: work(), + }), + }; + return { databasePath, protectionDeps }; +} + +function config(): OcxConfig { + return { port: 0, defaultProvider: "openai", providers: {} } as OcxConfig; +} + +async function request( + path: string, + init: RequestInit, + protectionDeps: CodexLogGuardProtectionDeps, +): Promise { + const req = new ManagementRequest(`http://localhost${path}`, init); + const response = await handleManagementAPI( + req, + new URL(req.url), + config(), + { codexLogGuardProtectionDeps: protectionDeps }, + ); + expect(response).not.toBeNull(); + return response!; +} + +afterEach(() => { + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpenCodexHome; + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Codex Log Guard protection management API", () => { + test("protect, drift repair, and unprotect round-trip desired and observed state", async () => { + const { databasePath, protectionDeps } = fixture(); + + const protect = await request("/api/storage/codex-logs/protect", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "compat" }), + }, protectionDeps); + expect(protect.status).toBe(200); + expect((await protect.json()).protection).toEqual({ + desiredMode: "compat", + observedMode: "compat", + state: "active", + }); + + const db = new Database(databasePath); + db.exec(` + ALTER TABLE logs RENAME TO logs_old; + 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 + ); + DROP TABLE logs_old; + `); + db.close(); + + const drift = await request("/api/storage/codex-logs", { method: "GET" }, protectionDeps); + expect(drift.status).toBe(200); + expect((await drift.json()).protection).toEqual({ + desiredMode: "compat", + observedMode: "off", + state: "drifted", + }); + + const repair = await request( + "/api/storage/codex-logs/repair", + { method: "POST" }, + protectionDeps, + ); + expect(repair.status).toBe(200); + expect((await repair.json()).protection.state).toBe("active"); + + const unprotect = await request( + "/api/storage/codex-logs/unprotect", + { method: "POST" }, + protectionDeps, + ); + expect(unprotect.status).toBe(200); + expect((await unprotect.json()).protection).toEqual({ + desiredMode: "off", + observedMode: "off", + state: "off", + }); + }); + + test("protect validates its mode before touching the database", async () => { + const { protectionDeps } = fixture(); + const response = await request("/api/storage/codex-logs/protect", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "maximum" }), + }, protectionDeps); + expect(response.status).toBe(400); + }); +}); diff --git a/tests/cli-codex-log-guard-protection.test.ts b/tests/cli-codex-log-guard-protection.test.ts new file mode 100644 index 0000000000..69dd104ebc --- /dev/null +++ b/tests/cli-codex-log-guard-protection.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; + +import { handleObserveCommand } from "../src/cli/observe"; + +function responseBody() { + return { + schema: { state: "compatible" }, + protection: { desiredMode: "compat", observedMode: "compat", state: "active" }, + }; +} + +describe("Codex Log Guard protection CLI", () => { + test("protect defaults to compat and POSTs through the management API", async () => { + const seen: Array<{ url: string; method: string; body: string }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + seen.push({ + url: String(input), + method: init?.method ?? "GET", + body: String(init?.body ?? ""), + }); + return new Response(JSON.stringify(responseBody()), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + const originalLog = console.log; + console.log = () => {}; + try { + const code = await handleObserveCommand( + ["storage", "codex-logs", "protect", "--json"], + { baseUrl: "http://runtime", fetchImpl }, + ); + expect(code).toBe(0); + expect(seen).toEqual([{ + url: "http://runtime/api/storage/codex-logs/protect", + method: "POST", + body: JSON.stringify({ mode: "compat" }), + }]); + } finally { + console.log = originalLog; + } + }); + + test("protect accepts quiet explicitly", async () => { + const bodies: string[] = []; + const fetchImpl: typeof fetch = async (_input, init) => { + bodies.push(String(init?.body ?? "")); + return new Response(JSON.stringify(responseBody()), { status: 200 }); + }; + const originalLog = console.log; + console.log = () => {}; + try { + expect(await handleObserveCommand( + ["storage", "codex-logs", "protect", "--mode", "quiet", "--json"], + { baseUrl: "http://runtime", fetchImpl }, + )).toBe(0); + expect(bodies).toEqual([JSON.stringify({ mode: "quiet" })]); + } finally { + console.log = originalLog; + } + }); + + test("unprotect and repair use dedicated POST endpoints", async () => { + const seen: Array<{ url: string; method: string }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + seen.push({ url: String(input), method: init?.method ?? "GET" }); + return new Response(JSON.stringify(responseBody()), { status: 200 }); + }; + const originalLog = console.log; + console.log = () => {}; + try { + expect(await handleObserveCommand( + ["storage", "codex-logs", "unprotect", "--json"], + { baseUrl: "http://runtime", fetchImpl }, + )).toBe(0); + expect(await handleObserveCommand( + ["storage", "codex-logs", "repair", "--json"], + { baseUrl: "http://runtime", fetchImpl }, + )).toBe(0); + expect(seen).toEqual([ + { url: "http://runtime/api/storage/codex-logs/unprotect", method: "POST" }, + { url: "http://runtime/api/storage/codex-logs/repair", method: "POST" }, + ]); + } finally { + console.log = originalLog; + } + }); + + test("rejects unknown protection modes before making a request", async () => { + let calls = 0; + const fetchImpl: typeof fetch = async () => { + calls += 1; + return new Response("{}", { status: 200 }); + }; + const originalError = console.error; + console.error = () => {}; + try { + const code = await handleObserveCommand( + ["storage", "codex-logs", "protect", "--mode", "maximum"], + { baseUrl: "http://runtime", fetchImpl }, + ); + expect(code).not.toBe(0); + expect(calls).toBe(0); + } finally { + console.error = originalError; + } + }); +}); diff --git a/tests/codex-app-server-path-spaces.test.ts b/tests/codex-app-server-path-spaces.test.ts new file mode 100644 index 0000000000..2d9ef0cef0 --- /dev/null +++ b/tests/codex-app-server-path-spaces.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; + +import { + isCodexAppServerCommandLine, + listCodexAppServerProcesses, +} from "../src/codex/app-server-processes"; + +describe("Codex app-server flattened command lines", () => { + test("matches unquoted official executable paths containing spaces", () => { + expect(isCodexAppServerCommandLine("/opt/Example Tools/codex app-server", "/opt/Example Tools/codex")).toBe(true); + expect(isCodexAppServerCommandLine("node /opt/Example Tools/codex-code-mode-host --session 1")).toBe(false); + expect(isCodexAppServerCommandLine("node worker.js codex app-server")).toBe(false); + expect(isCodexAppServerCommandLine("node worker.js codex-code-mode-host")).toBe(false); + }); + + test("does not discard PID 1 injected app-server snapshots", () => { + expect(listCodexAppServerProcesses({ + listSnapshots: () => [{ pid: 1, commandLine: "codex app-server" }], + })).toEqual([{ pid: 1, commandLine: "codex app-server" }]); + }); +}); diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index 8fd99ae703..7d89663ec3 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -437,7 +437,7 @@ describe("process utility invocation source guards", () => { ); test("pins every Darwin ps invocation to the system binary", () => { - expect(processSource.match(/execFileSync\(\s*["']\/bin\/ps["']/g) ?? []).toHaveLength(4); + expect(processSource.match(/execFileSync\(\s*["']\/bin\/ps["']/g) ?? []).toHaveLength(6); expect(processSource).not.toMatch(/execFileSync\(\s*["']ps["']/); }); }); diff --git a/tests/codex-log-guard-coderabbit.test.ts b/tests/codex-log-guard-coderabbit.test.ts new file mode 100644 index 0000000000..5710458497 --- /dev/null +++ b/tests/codex-log-guard-coderabbit.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { codexLogGuardLockDigest } from "../src/codex/log-guard/lock"; +import { sameLogGuardPathIdentity } from "../src/codex/log-guard/path-safety"; +import { + getCodexLogGuardProtectionStatus, + protectCodexLogs, + unprotectCodexLogs, +} from "../src/codex/log-guard/protection"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function createCurrentLogsDb(path: string): void { + const db = new Database(path); + db.exec(` + 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 + ); + CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_thread_id ON logs(thread_id); + CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_process_uuid_threadless_ts + ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) + WHERE thread_id IS NULL; + `); + db.close(); +} + +function fixture(): { codexHome: string; databasePath: string } { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-cr-protect-")); + roots.push(root); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + const databasePath = join(codexHome, "logs_2.sqlite"); + createCurrentLogsDb(databasePath); + return { codexHome, databasePath }; +} + +function deps(codexHome: string, writeDesiredMode?: (mode: "off" | "compat" | "quiet") => void) { + let desired: "off" | "compat" | "quiet" = "off"; + return { + codexHome, + processCheck: () => ({ state: "ok" as const, processes: [] }), + readDesiredMode: () => desired, + writeDesiredMode: (mode: "off" | "compat" | "quiet") => { + desired = mode; + writeDesiredMode?.(mode); + }, + withLock: (_home: string, _db: string, work: () => T) => ({ kind: "completed" as const, value: work() }), + }; +} + +function reservedTriggers(databasePath: string): Array<{ name: string; sql: string }> { + const db = new Database(databasePath, { readonly: true }); + try { + return db.query<{ name: string; sql: string }, []>( + "SELECT name, sql FROM sqlite_master WHERE type='trigger' AND name LIKE 'opencodex_log_guard_%' ORDER BY name", + ).all(); + } finally { + db.close(); + } +} + +describe("CodeRabbit protection regressions", () => { + test("compatible but unsafe trigger path reports unknown protection state", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-cr-symlink-")); + roots.push(root); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + const target = join(root, "real-logs.sqlite"); + createCurrentLogsDb(target); + symlinkSync(target, join(codexHome, "logs_2.sqlite")); + + const status = getCodexLogGuardProtectionStatus(deps(codexHome)); + expect(status.schema.state).toBe("compatible"); + expect(status.protection).toEqual({ desiredMode: "off", observedMode: "collision", state: "unknown" }); + }); + + test.skipIf(process.platform !== "darwin")( + "Darwin accepts only trusted system aliases and rejects an arbitrary ancestor symlink", + () => { + expect(sameLogGuardPathIdentity( + "/private/tmp/opencodex-log-guard/logs_2.sqlite", + "/tmp/opencodex-log-guard/logs_2.sqlite", + )).toBe(true); + expect(sameLogGuardPathIdentity( + "/private/var/tmp/opencodex-log-guard/logs_2.sqlite", + "/var/tmp/opencodex-log-guard/logs_2.sqlite", + )).toBe(true); + + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-cr-path-")); + roots.push(root); + const realParent = join(root, "real"); + const aliasParent = join(root, "alias"); + mkdirSync(realParent); + symlinkSync(realParent, aliasParent, "dir"); + + expect(sameLogGuardPathIdentity( + join(realParent, "logs_2.sqlite"), + join(aliasParent, "logs_2.sqlite"), + )).toBe(false); + }, + ); + + test.skipIf(process.platform !== "darwin")( + "Darwin trusted aliases share one Log Guard lock digest", + () => { + expect(codexLogGuardLockDigest( + "/tmp/opencodex-home", + "/var/tmp/opencodex-home/logs_2.sqlite", + )).toBe(codexLogGuardLockDigest( + "/private/tmp/opencodex-home", + "/private/var/tmp/opencodex-home/logs_2.sqlite", + )); + }, + ); + + test("successful mutation status honors a fresh unsupported inspection", () => { + const { codexHome, databasePath } = fixture(); + const result = protectCodexLogs("compat", deps(codexHome, () => { + const db = new Database(databasePath); + db.exec("ALTER TABLE logs ADD COLUMN future_field TEXT"); + db.close(); + })); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.status.capabilities.protection.state).toBe("unsupported"); + expect(result.status.protection.state).toBe("unsupported"); + }); + + test("unprotect recovers when both exact OpenCodex-owned triggers are present", () => { + const { codexHome, databasePath } = fixture(); + const testDeps = deps(codexHome); + expect(protectCodexLogs("compat", testDeps).ok).toBe(true); + + const db = new Database(databasePath); + db.exec(` + CREATE TRIGGER opencodex_log_guard_quiet_v1 + BEFORE INSERT ON logs + WHEN upper(NEW.level) = 'TRACE' + BEGIN + SELECT RAISE(IGNORE); + END; + `); + db.close(); + + const result = unprotectCodexLogs(testDeps); + expect(result.ok).toBe(true); + expect(reservedTriggers(databasePath)).toEqual([]); + }); + + test("config-write compensation restores every previously owned trigger definition", () => { + const { codexHome, databasePath } = fixture(); + expect(protectCodexLogs("compat", deps(codexHome)).ok).toBe(true); + + const db = new Database(databasePath); + db.exec(` + CREATE TRIGGER opencodex_log_guard_quiet_v1 + BEFORE INSERT ON logs + WHEN upper(NEW.level) = 'TRACE' + BEGIN + SELECT RAISE(IGNORE); + END; + `); + db.close(); + const before = reservedTriggers(databasePath); + + const result = protectCodexLogs("quiet", deps(codexHome, () => { + throw new Error("disk full"); + })); + + expect(result).toEqual({ ok: false, error: "config_write_failed" }); + expect(reservedTriggers(databasePath)).toEqual(before); + }); +}); diff --git a/tests/codex-log-guard-doctor-coderabbit.test.ts b/tests/codex-log-guard-doctor-coderabbit.test.ts new file mode 100644 index 0000000000..036708d4a0 --- /dev/null +++ b/tests/codex-log-guard-doctor-coderabbit.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; + +import { formatCodexLogGuardDoctor } from "../src/cli/codex-log-guard-doctor"; +import type { CodexLogGuardStatus } from "../src/codex/log-guard/protection"; + +function status(schema: CodexLogGuardStatus["schema"]): CodexLogGuardStatus { + const supported = schema.state === "compatible"; + const reason = schema.state === "compatible" ? undefined : schema.reason; + return { + generatedAt: 1, + codexHome: "/private/codex", + sqliteHome: "/private/sqlite", + databasePath: "/private/sqlite/logs_2.sqlite", + externalSqliteHome: true, + snapshot: "checkpointed", + files: { databaseBytes: 0, walBytes: 0, shmBytes: 0 }, + schema, + capabilities: { + inspection: { state: "supported" }, + protection: supported ? { state: "supported" } : { state: "unsupported", reason: reason! }, + reclaim: supported ? { state: "supported" } : { state: "unsupported", reason: reason! }, + }, + metrics: null, + protection: { + desiredMode: "compat", + observedMode: "off", + state: supported ? "drifted" : "unsupported", + }, + }; +} + +describe("CodeRabbit doctor regressions", () => { + test("missing database still reports protection unavailable", () => { + const text = formatCodexLogGuardDoctor(status({ state: "missing", reason: "database_missing" })).join("\n"); + expect(text).toContain("protection unavailable"); + expect(text).toContain("logs_2.sqlite is not present"); + }); + + test("unreadable database still reports protection unavailable", () => { + const text = formatCodexLogGuardDoctor(status({ state: "unreadable", reason: "database_unreadable" })).join("\n"); + expect(text).toContain("protection unavailable"); + expect(text).toContain("logs_2.sqlite is unreadable"); + }); +}); diff --git a/tests/codex-log-guard-doctor-protection.test.ts b/tests/codex-log-guard-doctor-protection.test.ts new file mode 100644 index 0000000000..7d4c0d18d4 --- /dev/null +++ b/tests/codex-log-guard-doctor-protection.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; + +import { formatCodexLogGuardDoctor, printCodexLogGuardDoctor } from "../src/cli/codex-log-guard-doctor"; +import type { CodexLogGuardStatus } from "../src/codex/log-guard/protection"; + +function status(state: CodexLogGuardStatus["protection"]): CodexLogGuardStatus { + return { + generatedAt: 1, + externalSqliteHome: false, + snapshot: "checkpointed", + files: { databaseBytes: 4096, walBytes: 0, shmBytes: 0 }, + schema: { state: "compatible" }, + capabilities: { + inspection: { state: "supported" }, + protection: { state: "supported" }, + reclaim: { state: "supported" }, + }, + metrics: null, + protection: state, + }; +} + +describe("Codex Log Guard doctor protection output", () => { + test("reports active compat protection without attempting repair", () => { + const text = formatCodexLogGuardDoctor(status({ + desiredMode: "compat", + observedMode: "compat", + state: "active", + })).join("\n"); + expect(text).toContain("protection active (compat)"); + expect(text).not.toContain("Repairing"); + }); + + test("warns on drift and tells the user the explicit repair command", () => { + const text = formatCodexLogGuardDoctor(status({ + desiredMode: "compat", + observedMode: "off", + state: "drifted", + })).join("\n"); + expect(text).toContain("protection drifted"); + expect(text).toContain("ocx storage codex-logs repair"); + }); + + test("reports disabled protection as informational", () => { + const text = formatCodexLogGuardDoctor(status({ + desiredMode: "off", + observedMode: "off", + state: "off", + })).join("\n"); + expect(text).toContain("protection off"); + }); + + test("redacts injected inspector failures without falling through to the real Codex home", () => { + const lines: string[] = []; + printCodexLogGuardDoctor({ + inspect: () => { throw new Error("synthetic-inspector-failure"); }, + log: line => lines.push(line), + }); + const text = lines.join("\n"); + expect(text).toContain("inspection unavailable"); + expect(text).not.toContain("synthetic-inspector-failure"); + }); +}); diff --git a/tests/codex-log-guard-lock.test.ts b/tests/codex-log-guard-lock.test.ts new file mode 100644 index 0000000000..414372aa52 --- /dev/null +++ b/tests/codex-log-guard-lock.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { withCodexLogGuardLock } from "../src/codex/log-guard/lock"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Codex Log Guard lock", () => { + test("fails fast on same-database contention without using history H", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-lock-")); + roots.push(root); + const lockPath = join(root, "log-guard-lock.sqlite"); + const codexHome = join(root, "codex-home"); + const logsDb = join(root, "logs_2.sqlite"); + const deps = { resolveDatabasePath: () => lockPath }; + + const outer = withCodexLogGuardLock(codexHome, logsDb, () => { + return withCodexLogGuardLock(codexHome, logsDb, () => "inner", deps); + }, deps); + + expect(outer.kind).toBe("completed"); + if (outer.kind !== "completed") return; + expect(outer.value).toEqual({ kind: "unavailable", reason: "busy" }); + }); +}); diff --git a/tests/codex-log-guard-policy.test.ts b/tests/codex-log-guard-policy.test.ts new file mode 100644 index 0000000000..3f76d3f59d --- /dev/null +++ b/tests/codex-log-guard-policy.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; + +import { readCodexLogGuardMode, writeCodexLogGuardMode } from "../src/codex/log-guard/policy"; +import type { OcxConfig } from "../src/types"; + +describe("Codex Log Guard desired-state policy", () => { + test("defaults missing or invalid modes to off", () => { + const base = { port: 0, defaultProvider: "openai", providers: {} } as OcxConfig; + expect(readCodexLogGuardMode({ load: () => base })).toBe("off"); + expect(readCodexLogGuardMode({ + load: () => ({ ...base, codexLogGuard: { mode: "maximum" } }) as OcxConfig, + })).toBe("off"); + }); + + test("persists mode while preserving sibling Log Guard settings", () => { + const base = { + port: 0, + defaultProvider: "openai", + providers: {}, + codexLogGuard: { mode: "off", futureSetting: 7 }, + } as unknown as OcxConfig; + let saved: OcxConfig | null = null; + writeCodexLogGuardMode("quiet", { + load: () => structuredClone(base), + save: config => { saved = config; }, + }); + expect(saved).not.toBeNull(); + expect((saved as unknown as { codexLogGuard: Record }).codexLogGuard).toEqual({ + mode: "quiet", + futureSetting: 7, + }); + }); +}); diff --git a/tests/codex-log-guard-processes.test.ts b/tests/codex-log-guard-processes.test.ts new file mode 100644 index 0000000000..b733a956c1 --- /dev/null +++ b/tests/codex-log-guard-processes.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; + +import { + isCodexWriterCommandLine, + listRunningCodexProcesses, +} from "../src/codex/log-guard/processes"; + +describe("Codex Log Guard process gate", () => { + test("matches official Codex writer command lines without broad codex substring matching", () => { + expect(isCodexWriterCommandLine("codex")).toBe(true); + expect(isCodexWriterCommandLine("/usr/local/bin/codex exec --json")).toBe(true); + expect(isCodexWriterCommandLine("codex --model gpt-5 app-server")).toBe(true); + expect(isCodexWriterCommandLine("/opt/codex-x86_64-unknown-linux-musl exec")).toBe(true); + expect(isCodexWriterCommandLine("codex-code-mode-host")).toBe(true); + expect(isCodexWriterCommandLine("node /opt/codex-code-mode-host")).toBe(true); + expect(isCodexWriterCommandLine("/opt/Example Tools/codex exec --json", "/opt/Example Tools/codex")).toBe(true); + expect(isCodexWriterCommandLine("node /opt/Example Tools/codex-code-mode-host")).toBe(false); + expect(isCodexWriterCommandLine("node worker.js codex exec")).toBe(false); + expect(isCodexWriterCommandLine("node worker.js codex-code-mode-host")).toBe(false); + expect(isCodexWriterCommandLine("bun /repo/opencodex/src/cli/index.ts storage codex-logs protect")).toBe(false); + expect(isCodexWriterCommandLine("hermes-codex-bridge-mcp")).toBe(false); + }); + + test("fails closed when enumeration throws", () => { + expect(listRunningCodexProcesses({ + platform: "linux", + listSnapshots: () => { throw new Error("procfs unavailable"); }, + })).toEqual({ state: "unknown", reason: "enumeration_failed" }); + }); + + test("reports PID 1 when it is an official Codex writer", () => { + expect(listRunningCodexProcesses({ + platform: "linux", + listSnapshots: () => [{ pid: 1, commandLine: "codex exec --json" }], + })).toEqual({ + state: "ok", + processes: [{ pid: 1, commandLine: "codex exec --json" }], + }); + }); + + test("deduplicates matching current-user writer processes", () => { + const result = listRunningCodexProcesses({ + platform: "linux", + listSnapshots: () => [ + { pid: 10, commandLine: "codex exec" }, + { pid: 10, commandLine: "codex exec" }, + { pid: 11, commandLine: "node worker.js codex exec" }, + { pid: 12, commandLine: "codex app-server" }, + ], + }); + expect(result).toEqual({ + state: "ok", + processes: [ + { pid: 10, commandLine: "codex exec" }, + { pid: 12, commandLine: "codex app-server" }, + ], + }); + }); +}); diff --git a/tests/codex-log-guard-protection.test.ts b/tests/codex-log-guard-protection.test.ts new file mode 100644 index 0000000000..765240c946 --- /dev/null +++ b/tests/codex-log-guard-protection.test.ts @@ -0,0 +1,249 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + getCodexLogGuardProtectionStatus, + protectCodexLogs, + unprotectCodexLogs, +} from "../src/codex/log-guard/protection"; + +const roots: string[] = []; + +function makeRoot(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-protect-")); + roots.push(root); + return root; +} + +function createCurrentLogsDb(path: string): void { + const db = new Database(path); + db.exec(` + 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 + ); + CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_thread_id ON logs(thread_id); + CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_process_uuid_threadless_ts + ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) + WHERE thread_id IS NULL; + `); + db.close(); +} + +function fixture(): { codexHome: string; databasePath: string } { + const root = makeRoot(); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + const databasePath = join(codexHome, "logs_2.sqlite"); + createCurrentLogsDb(databasePath); + return { codexHome, databasePath }; +} + +function rows(path: string): Array<{ level: string; target: string }> { + const db = new Database(path, { readonly: true }); + try { + return db.query<{ level: string; target: string }, []>( + "SELECT level, target FROM logs ORDER BY id", + ).all(); + } finally { + db.close(); + } +} + +function triggers(path: string): Array<{ name: string; sql: string }> { + const db = new Database(path, { readonly: true }); + try { + return db.query<{ name: string; sql: string }, []>( + "SELECT name, sql FROM sqlite_master WHERE type = 'trigger' ORDER BY name", + ).all(); + } finally { + db.close(); + } +} + +function insert(path: string, level: string, target: string): void { + const db = new Database(path); + try { + db.query( + "INSERT INTO logs (ts, ts_nanos, level, target, feedback_log_body, estimated_bytes) VALUES (?, 0, ?, ?, ?, 1)", + ).run(Date.now(), level, target, "PRIVATE"); + } finally { + db.close(); + } +} + +function testDeps(codexHome: string, initial: "off" | "compat" | "quiet" = "off") { + let desired = initial; + return { + codexHome, + processCheck: () => ({ state: "ok" as const, processes: [] }), + readDesiredMode: () => desired, + writeDesiredMode: (mode: "off" | "compat" | "quiet") => { desired = mode; }, + withLock: (_home: string, _db: string, work: () => T) => ({ kind: "completed" as const, value: work() }), + desired: () => desired, + }; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Codex Log Guard protection", () => { + test("compat suppresses only the pinned upstream noisy set and preserves unrelated TRACE", async () => { + const { codexHome, databasePath } = fixture(); + const deps = testDeps(codexHome); + const result = protectCodexLogs("compat", deps); + expect(result.ok).toBe(true); + expect(deps.desired()).toBe("compat"); + + insert(databasePath, "TRACE", "codex_api::sse"); + insert(databasePath, "TRACE", "opentelemetry_sdk"); + insert(databasePath, "TRACE", "opentelemetry_sdk::trace"); + insert(databasePath, "TRACE", "custom::trace"); + insert(databasePath, "DEBUG", "rmcp"); + insert(databasePath, "WARN", "hyper_util"); + insert(databasePath, "INFO", "codex_core"); + + expect(rows(databasePath)).toEqual([ + { level: "TRACE", target: "opentelemetry_sdk::trace" }, + { level: "TRACE", target: "custom::trace" }, + { level: "WARN", target: "hyper_util" }, + { level: "INFO", target: "codex_core" }, + ]); + expect(triggers(databasePath).map(row => row.name)).toEqual(["opencodex_log_guard_compat_v1"]); + }); + + test("quiet suppresses all TRACE while preserving DEBUG INFO WARN and ERROR", async () => { + const { codexHome, databasePath } = fixture(); + const deps = testDeps(codexHome); + expect(protectCodexLogs("quiet", deps).ok).toBe(true); + + for (const level of ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]) { + insert(databasePath, level, "custom::target"); + } + expect(rows(databasePath).map(row => row.level)).toEqual(["DEBUG", "INFO", "WARN", "ERROR"]); + }); + + test("refuses unknown schemas without changing desired state or installing a trigger", async () => { + const { codexHome, databasePath } = fixture(); + const db = new Database(databasePath); + db.exec("ALTER TABLE logs ADD COLUMN future_field TEXT"); + db.close(); + const deps = testDeps(codexHome); + + const result = protectCodexLogs("compat", deps); + expect(result).toEqual({ ok: false, error: "unsupported_schema" }); + expect(deps.desired()).toBe("off"); + expect(triggers(databasePath)).toEqual([]); + }); + + test("fails closed when Codex is running or process enumeration is unknown", async () => { + const running = fixture(); + const runningDeps = { + ...testDeps(running.codexHome), + processCheck: () => ({ state: "ok" as const, processes: [{ pid: 42, commandLine: "codex exec" }] }), + }; + expect(protectCodexLogs("compat", runningDeps)).toEqual({ ok: false, error: "codex_running" }); + expect(triggers(running.databasePath)).toEqual([]); + + const unknown = fixture(); + const unknownDeps = { + ...testDeps(unknown.codexHome), + processCheck: () => ({ state: "unknown" as const, reason: "enumeration_failed" as const }), + }; + expect(protectCodexLogs("compat", unknownDeps)).toEqual({ ok: false, error: "process_enumeration_failed" }); + expect(triggers(unknown.databasePath)).toEqual([]); + }); + + test("never overwrites a trigger-name collision", async () => { + const { codexHome, databasePath } = fixture(); + const db = new Database(databasePath); + db.exec(` + CREATE TRIGGER opencodex_log_guard_compat_v1 BEFORE INSERT ON logs + BEGIN SELECT 1; END; + `); + db.close(); + const before = triggers(databasePath); + + const result = protectCodexLogs("compat", testDeps(codexHome)); + expect(result).toEqual({ ok: false, error: "trigger_collision" }); + expect(triggers(databasePath)).toEqual(before); + }); + + test("reports drift after a Codex-style table rebuild drops the owned trigger", async () => { + const { codexHome, databasePath } = fixture(); + const deps = testDeps(codexHome); + expect(protectCodexLogs("compat", deps).ok).toBe(true); + expect(getCodexLogGuardProtectionStatus(deps).protection.state).toBe("active"); + + const db = new Database(databasePath); + db.exec(` + ALTER TABLE logs RENAME TO logs_old; + 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 + ); + DROP TABLE logs_old; + CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_thread_id ON logs(thread_id); + CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_process_uuid_threadless_ts + ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) + WHERE thread_id IS NULL; + `); + db.close(); + + const status = getCodexLogGuardProtectionStatus(deps); + expect(status.protection).toEqual({ desiredMode: "compat", observedMode: "off", state: "drifted" }); + }); + + test("unprotect removes only OpenCodex-owned triggers", async () => { + const { codexHome, databasePath } = fixture(); + const deps = testDeps(codexHome); + expect(protectCodexLogs("quiet", deps).ok).toBe(true); + const db = new Database(databasePath); + db.exec("CREATE TRIGGER user_trigger BEFORE INSERT ON logs BEGIN SELECT 1; END;"); + db.close(); + + expect(unprotectCodexLogs(deps).ok).toBe(true); + expect(deps.desired()).toBe("off"); + expect(triggers(databasePath).map(row => row.name)).toEqual(["user_trigger"]); + }); + + test("rolls back an installed trigger when persisting desired state fails", async () => { + const { codexHome, databasePath } = fixture(); + const deps = { + ...testDeps(codexHome), + writeDesiredMode: (_mode: "off" | "compat" | "quiet") => { throw new Error("disk full"); }, + }; + + expect(protectCodexLogs("compat", deps)).toEqual({ ok: false, error: "config_write_failed" }); + expect(triggers(databasePath)).toEqual([]); + }); +}); diff --git a/tests/codex-log-guard-status-zero-write.test.ts b/tests/codex-log-guard-status-zero-write.test.ts new file mode 100644 index 0000000000..d4b4ce36b2 --- /dev/null +++ b/tests/codex-log-guard-status-zero-write.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { getCodexLogGuardProtectionStatus } from "../src/codex/log-guard/protection"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture(): { codexHome: string; databasePath: string } { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-status-")); + roots.push(root); + const codexHome = join(root, "codex-home"); + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + const databasePath = join(codexHome, "logs_2.sqlite"); + const db = new Database(databasePath); + db.exec(` + 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 + ); + CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_thread_id ON logs(thread_id); + CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_process_uuid_threadless_ts + ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) + WHERE thread_id IS NULL; + INSERT INTO logs (ts, ts_nanos, level, target, feedback_log_body, estimated_bytes) + VALUES (1, 0, 'TRACE', 'codex_api::sse', 'PRIVATE', 10); + `); + db.close(); + return { codexHome, databasePath }; +} + +describe("Codex Log Guard status remains zero-write", () => { + test("status does not change the DB or materialise WAL/SHM sidecars", () => { + const { codexHome, databasePath } = fixture(); + const before = statSync(databasePath); + const wal = `${databasePath}-wal`; + const shm = `${databasePath}-shm`; + expect(existsSync(wal)).toBe(false); + expect(existsSync(shm)).toBe(false); + + const status = getCodexLogGuardProtectionStatus({ + codexHome, + readDesiredMode: () => "off", + }); + + expect(status.protection).toEqual({ desiredMode: "off", observedMode: "off", state: "off" }); + const after = statSync(databasePath); + expect(after.size).toBe(before.size); + expect(after.mtimeMs).toBe(before.mtimeMs); + expect(existsSync(wal)).toBe(false); + expect(existsSync(shm)).toBe(false); + }); + + test("WAL-mode status does not materialise SHM or journal and leaves DB/WAL unchanged", () => { + const { codexHome, databasePath } = fixture(); + const writer = new Database(databasePath); + writer.exec("PRAGMA journal_mode=WAL"); + writer.query( + "INSERT INTO logs (ts, ts_nanos, level, target, feedback_log_body, estimated_bytes) VALUES (2, 0, 'INFO', 'test', NULL, 1)", + ).run(); + + const wal = `${databasePath}-wal`; + const shm = `${databasePath}-shm`; + const rollbackJournal = `${databasePath}-journal`; + expect(existsSync(wal)).toBe(true); + const liveWal = readFileSync(wal); + writer.close(); + + // Closing the last SQLite connection normally checkpoints/removes WAL/SHM. + // Restore the genuine WAL bytes without an SHM file so the status read must + // prove it does not join/materialise SQLite's WAL protocol. + rmSync(shm, { force: true }); + writeFileSync(wal, liveWal); + expect(existsSync(wal)).toBe(true); + expect(existsSync(shm)).toBe(false); + expect(existsSync(rollbackJournal)).toBe(false); + const beforeDb = statSync(databasePath); + const beforeWal = statSync(wal); + + const status = getCodexLogGuardProtectionStatus({ + codexHome, + readDesiredMode: () => "off", + }); + + expect(status.protection).toEqual({ desiredMode: "off", observedMode: "off", state: "off" }); + const afterDb = statSync(databasePath); + const afterWal = statSync(wal); + expect({ size: afterDb.size, mtimeMs: afterDb.mtimeMs }).toEqual({ size: beforeDb.size, mtimeMs: beforeDb.mtimeMs }); + expect({ size: afterWal.size, mtimeMs: afterWal.mtimeMs }).toEqual({ size: beforeWal.size, mtimeMs: beforeWal.mtimeMs }); + expect(existsSync(shm)).toBe(false); + expect(existsSync(rollbackJournal)).toBe(false); + }); +}); From 7f544ce3f9ad59495893bda01a5da8fcd2d91946 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:27:04 +0200 Subject: [PATCH 2/5] test(log-guard): preserve canonical indexes in API drift rebuild --- tests/api-codex-log-guard-protection.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/api-codex-log-guard-protection.test.ts b/tests/api-codex-log-guard-protection.test.ts index 336410821c..df86a9a499 100644 --- a/tests/api-codex-log-guard-protection.test.ts +++ b/tests/api-codex-log-guard-protection.test.ts @@ -134,6 +134,12 @@ describe("Codex Log Guard protection management API", () => { estimated_bytes INTEGER NOT NULL DEFAULT 0 ); DROP TABLE logs_old; + CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_thread_id ON logs(thread_id); + CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC); + CREATE INDEX idx_logs_process_uuid_threadless_ts + ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) + WHERE thread_id IS NULL; `); db.close(); From 017b9bce7571d1240dd12a8867e81846c43f0837 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 16 Aug 2026 09:28:13 +0900 Subject: [PATCH 3/5] fix(log-guard): match descendant targets and resolve Repair mode under the lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. compat did not reproduce upstream prefix matching. Upstream registers these filters with `Targets::with_target`, which matches a target AND every module beneath it. The trigger used exact equality, so only the parent was suppressed while the high-volume children kept writing: hyper_util::client::legacy::pool codex_api::sse::responses rmcp::service codex_http_client::transport::wire All four persisted with compat reporting itself active, contradicting the guide. Comparison is now `target = X OR target LIKE X || '::%' ESCAPE '\\'`. The ESCAPE clause is load-bearing rather than defensive: `_` is a single-character LIKE wildcard, so an unescaped `hyper_util` would also match `hyperXutil`. Every `_`, `%` and backslash is escaped before interpolation, and the `'::'` boundary keeps a near-prefix such as `hyper_utilities` out. `opentelemetry_sdk` stays exact because upstream registers it that way; the existing test asserting `opentelemetry_sdk::trace` survives still passes. 2. A concurrent Repair could undo a completed Disable. `repairCodexLogGuardProtection` read the desired mode BEFORE acquiring the cross-process lock. A Disable landing in that gap completed successfully and was then silently reinstalled by the stale Repair, so the caller saw "off" and got "compat" — violating the serialization guarantee the lock documents. Repair now passes a resolver that `performMutation` evaluates inside the lock, and the returned status reports the mode actually applied rather than the one guessed beforehand. Regressions for both, including the near-prefix negative case and a deterministic Repair-versus-Disable interleaving. 10 pass; without the source change exactly these two fail. --- src/codex/log-guard/protection.ts | 64 ++++++++++++++++++------ tests/codex-log-guard-protection.test.ts | 56 +++++++++++++++++++++ 2 files changed, 106 insertions(+), 14 deletions(-) diff --git a/src/codex/log-guard/protection.ts b/src/codex/log-guard/protection.ts index 55723886e7..4b78367971 100644 --- a/src/codex/log-guard/protection.ts +++ b/src/codex/log-guard/protection.ts @@ -44,6 +44,33 @@ const CURRENT_LOG_COLUMNS = [ * log filters researched for Log Guard v1. It intentionally preserves unrelated * TRACE rows rather than assuming all TRACE diagnostics are disposable. */ +/** + * Upstream configures these filters with `Targets::with_target`, which matches a + * target and every module path BENEATH it: `hyper_util` also covers + * `hyper_util::client::legacy::pool`, and `codex_api::sse` also covers + * `codex_api::sse::responses`. Exact equality reproduced only the parent, so the + * high-volume child targets — the ones that actually fill the database — kept + * writing while compat mode reported itself active. + * + * The comparison is `target = X OR target LIKE X || '::%' ESCAPE '\\'`. The + * ESCAPE clause is load-bearing: `_` is a single-character LIKE wildcard, so an + * unescaped `hyper_util` would also match `hyperXutil`, and `hyper_utilities` + * would slip past the boundary. Every `_`, `%` and `\\` in the literal is + * escaped before interpolation. The trailing `'::'` keeps a sibling crate whose + * name merely starts with the same letters from matching. + * + * `opentelemetry_sdk` stays exact because upstream registers it with exact + * equality rather than a prefix filter. + */ +function targetOrDescendant(target: string): string { + const escaped = target.replace(/[\\%_]/g, ch => `\\${ch}`); + return `(NEW.target = '${target}' OR NEW.target LIKE '${escaped}::%' ESCAPE '\\')`; +} + +function anyTargetOrDescendant(targets: readonly string[]): string { + return `(${targets.map(targetOrDescendant).join(" OR ")})`; +} + const COMPAT_TRIGGER_SQL = `CREATE TRIGGER ${COMPAT_TRIGGER} BEFORE INSERT ON logs WHEN @@ -52,14 +79,14 @@ WHEN OR NEW.target = 'codex_otel.trace_safe' OR NEW.target = 'codex_api::responses_websocket_timing' OR NEW.target = 'codex_core::post_sampling_token_estimate' - OR (NEW.target = 'hyper_util' AND upper(NEW.level) IN ('TRACE', 'DEBUG', 'INFO')) - OR (NEW.target IN ('codex_rmcp_client', 'rmcp') AND upper(NEW.level) IN ('TRACE', 'DEBUG')) - OR (NEW.target IN ( - 'codex_http_client::transport', - 'codex_api::sse', - 'codex_tui::streaming::controller', - 'codex_tui::streaming::table_holdback' - ) AND upper(NEW.level) = 'TRACE') + OR (${targetOrDescendant("hyper_util")} AND upper(NEW.level) IN ('TRACE', 'DEBUG', 'INFO')) + OR (${anyTargetOrDescendant(["codex_rmcp_client", "rmcp"])} AND upper(NEW.level) IN ('TRACE', 'DEBUG')) + OR (${anyTargetOrDescendant([ + "codex_http_client::transport", + "codex_api::sse", + "codex_tui::streaming::controller", + "codex_tui::streaming::table_holdback", + ])} AND upper(NEW.level) = 'TRACE') OR (NEW.target = 'opentelemetry_sdk' AND upper(NEW.level) IN ('TRACE', 'DEBUG')) BEGIN SELECT RAISE(IGNORE); @@ -355,7 +382,7 @@ function restoreOwnedTriggers(databasePath: string, previousTriggers: readonly O } function performMutation( - requestedMode: CodexLogGuardMode, + requestedMode: CodexLogGuardMode | (() => CodexLogGuardMode), deps: CodexLogGuardProtectionDeps, ): CodexLogGuardMutationResult { const codexHome = deps.codexHome ?? getCodexHome(); @@ -373,6 +400,11 @@ function performMutation( const withLock = deps.withLock ?? withCodexLogGuardLock; const writeDesired = deps.writeDesiredMode ?? writeCodexLogGuardMode; let locked: CodexLogGuardLockOutcome; + // Repair passes a resolver instead of a value: its target mode must be read + // INSIDE L. Reading it before acquiring the lock let a Disable complete in + // the gap, after which the stale Repair reinstalled protection and reported + // success — the caller saw an honest "off" and got "compat". + let effectiveMode: CodexLogGuardMode = typeof requestedMode === "function" ? "off" : requestedMode; try { locked = withLock(codexHome, databasePath, () => { // Recheck after acquiring L so a Codex process that starts during lock @@ -380,14 +412,15 @@ function performMutation( const secondRefusal = processRefusal(checkProcesses()); if (secondRefusal) return { ok: false, error: secondRefusal }; - const mutation = mutateOwnedTrigger(databasePath, requestedMode); + effectiveMode = typeof requestedMode === "function" ? requestedMode() : requestedMode; + const mutation = mutateOwnedTrigger(databasePath, effectiveMode); if (!mutation.ok) return mutation; // Desired state belongs to the same logical transition as the trigger. // Keep L held through this write so another OpenCodex process cannot // interleave a different mode between the DB commit and config commit. try { - writeDesired(requestedMode); + writeDesired(effectiveMode); } catch { restoreOwnedTriggers(databasePath, mutation.previousTriggers); return { ok: false, error: "config_write_failed" as const }; @@ -407,7 +440,9 @@ function performMutation( } if (!locked.value.ok) return locked.value; - return { ok: true, status: successfulMutationStatus(codexHome, requestedMode) }; + // Report the mode that was actually applied under the lock, not the one the + // caller guessed before acquiring it. + return { ok: true, status: successfulMutationStatus(codexHome, effectiveMode) }; } export function protectCodexLogs( @@ -426,6 +461,7 @@ export function unprotectCodexLogs( export function repairCodexLogGuardProtection( deps: CodexLogGuardProtectionDeps = {}, ): CodexLogGuardMutationResult { - const desired = (deps.readDesiredMode ?? readCodexLogGuardMode)(); - return performMutation(desired, deps); + // Resolve the desired mode under the lock (see performMutation): a Disable + // that lands between the read and the lock must win, not be silently undone. + return performMutation(() => (deps.readDesiredMode ?? readCodexLogGuardMode)(), deps); } diff --git a/tests/codex-log-guard-protection.test.ts b/tests/codex-log-guard-protection.test.ts index 765240c946..3f9db4d9de 100644 --- a/tests/codex-log-guard-protection.test.ts +++ b/tests/codex-log-guard-protection.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { getCodexLogGuardProtectionStatus, protectCodexLogs, + repairCodexLogGuardProtection, unprotectCodexLogs, } from "../src/codex/log-guard/protection"; @@ -129,6 +130,61 @@ describe("Codex Log Guard protection", () => { expect(triggers(databasePath).map(row => row.name)).toEqual(["opencodex_log_guard_compat_v1"]); }); + test("compat suppresses descendant targets, matching upstream Targets prefix semantics", async () => { + // Upstream registers these with `Targets::with_target`, which matches the + // target AND every module beneath it. Exact equality caught only the parent, + // so the high-volume children that actually fill the database kept writing + // while compat reported itself active. + const { codexHome, databasePath } = fixture(); + const deps = testDeps(codexHome); + expect(protectCodexLogs("compat", deps).ok).toBe(true); + + insert(databasePath, "TRACE", "hyper_util::client::legacy::pool"); + insert(databasePath, "TRACE", "codex_api::sse::responses"); + insert(databasePath, "TRACE", "rmcp::service"); + insert(databasePath, "TRACE", "codex_http_client::transport::wire"); + // A near-prefix must NOT match: the '::' boundary is what separates a child + // module from an unrelated crate that merely starts with the same letters. + insert(databasePath, "TRACE", "hyper_utilities"); + // opentelemetry_sdk stays exact upstream, so its children are preserved. + insert(databasePath, "TRACE", "opentelemetry_sdk::trace"); + + expect(rows(databasePath)).toEqual([ + { level: "TRACE", target: "hyper_utilities" }, + { level: "TRACE", target: "opentelemetry_sdk::trace" }, + ]); + }); + + test("a Disable that lands during Repair is not silently undone", async () => { + // Repair used to read the desired mode BEFORE acquiring the lock. A Disable + // completing in that gap was reported successful and then reinstalled by the + // stale Repair, so the caller saw 'off' and got 'compat'. + const { codexHome, databasePath } = fixture(); + const deps = testDeps(codexHome); + expect(protectCodexLogs("compat", deps).ok).toBe(true); + expect(deps.desired()).toBe("compat"); + + let interleaved = false; + const repair = repairCodexLogGuardProtection({ + ...deps, + readDesiredMode: () => { + // Runs inside the lock now; the Disable below already committed. + return deps.desired() as never; + }, + withLock: (home, path, run) => { + if (!interleaved) { + interleaved = true; + expect(unprotectCodexLogs(deps).ok).toBe(true); + expect(deps.desired()).toBe("off"); + } + return deps.withLock(home, path, run); + }, + }); + + expect(repair.ok).toBe(true); + expect(deps.desired()).toBe("off"); + expect(triggers(databasePath)).toEqual([]); + }); test("quiet suppresses all TRACE while preserving DEBUG INFO WARN and ERROR", async () => { const { codexHome, databasePath } = fixture(); const deps = testDeps(codexHome); From 23308441db674fd24669844c7c57beb1531d7ce3 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 16 Aug 2026 09:48:36 +0900 Subject: [PATCH 4/5] fix(log-guard): make descendant matching case-sensitive via substr, not LIKE Re-review found the LIKE form over-broad: SQLite LIKE is ASCII case-insensitive by default, so 'hyper_util::%' also suppressed HYPER_UTIL::child. Rust target paths are case-sensitive, and silently discarding a differently-cased target is a wrong answer rather than a safe default. substr(NEW.target, 1, N) = 'X::' is a plain case-sensitive comparison with no wildcard metacharacters, which also removes the _/% hazard that LIKE needed an ESCAPE clause to contain. Documented the deliberate narrowing: upstream's raw prefix rule would also match a sibling crate named hyper_utilities. Suppressing an unrelated crate's logs is worse for a guard that silently discards rows, so this matches the module-descendant relation instead; the existing regression pins hyper_utilities as preserved, and HYPER_UTIL::child is now pinned alongside it. --- src/codex/log-guard/protection.ts | 24 ++++++++++++++++-------- tests/codex-log-guard-protection.test.ts | 5 +++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/codex/log-guard/protection.ts b/src/codex/log-guard/protection.ts index 4b78367971..a3a40c4e80 100644 --- a/src/codex/log-guard/protection.ts +++ b/src/codex/log-guard/protection.ts @@ -52,19 +52,27 @@ const CURRENT_LOG_COLUMNS = [ * high-volume child targets — the ones that actually fill the database — kept * writing while compat mode reported itself active. * - * The comparison is `target = X OR target LIKE X || '::%' ESCAPE '\\'`. The - * ESCAPE clause is load-bearing: `_` is a single-character LIKE wildcard, so an - * unescaped `hyper_util` would also match `hyperXutil`, and `hyper_utilities` - * would slip past the boundary. Every `_`, `%` and `\\` in the literal is - * escaped before interpolation. The trailing `'::'` keeps a sibling crate whose - * name merely starts with the same letters from matching. + * The comparison uses `substr`, not `LIKE`. SQLite's `LIKE` is ASCII + * case-insensitive by default, so a `LIKE` form would also suppress + * `HYPER_UTIL::child` — Rust target paths are case-sensitive, and silently + * dropping a differently-cased target is a wrong answer, not a safe default. + * `substr(NEW.target, 1, N) = 'X::'` is a plain case-sensitive comparison with + * no wildcard metacharacters to escape, which also removes the `_`/`%` hazard + * that `LIKE` would have required an ESCAPE clause to contain. + * + * The `'::'` boundary is deliberate and NARROWER than upstream's raw prefix + * rule: `Targets::with_target("hyper_util")` would also match a sibling crate + * named `hyper_utilities`. Suppressing an unrelated crate's logs is worse for + * a guard that silently discards rows, so this matches the module-descendant + * relation instead. The existing regression pins `hyper_utilities` as + * preserved. * * `opentelemetry_sdk` stays exact because upstream registers it with exact * equality rather than a prefix filter. */ function targetOrDescendant(target: string): string { - const escaped = target.replace(/[\\%_]/g, ch => `\\${ch}`); - return `(NEW.target = '${target}' OR NEW.target LIKE '${escaped}::%' ESCAPE '\\')`; + const prefix = `${target}::`; + return `(NEW.target = '${target}' OR substr(NEW.target, 1, ${prefix.length}) = '${prefix}')`; } function anyTargetOrDescendant(targets: readonly string[]): string { diff --git a/tests/codex-log-guard-protection.test.ts b/tests/codex-log-guard-protection.test.ts index 3f9db4d9de..b98df62379 100644 --- a/tests/codex-log-guard-protection.test.ts +++ b/tests/codex-log-guard-protection.test.ts @@ -146,11 +146,16 @@ describe("Codex Log Guard protection", () => { // A near-prefix must NOT match: the '::' boundary is what separates a child // module from an unrelated crate that merely starts with the same letters. insert(databasePath, "TRACE", "hyper_utilities"); + // Rust target paths are case-sensitive. SQLite LIKE is ASCII + // case-insensitive, so a LIKE-based prefix would have suppressed this + // unrelated target too; substr() keeps the comparison exact. + insert(databasePath, "TRACE", "HYPER_UTIL::child"); // opentelemetry_sdk stays exact upstream, so its children are preserved. insert(databasePath, "TRACE", "opentelemetry_sdk::trace"); expect(rows(databasePath)).toEqual([ { level: "TRACE", target: "hyper_utilities" }, + { level: "TRACE", target: "HYPER_UTIL::child" }, { level: "TRACE", target: "opentelemetry_sdk::trace" }, ]); }); From f7f6baad999ca636e56357eaa37d9e73846f9f22 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 16 Aug 2026 11:33:48 +0900 Subject: [PATCH 5/5] fix(log-guard): allow Disable on an unrecognized schema; repair a stale lock dir mode Two review findings. A Codex schema upgrade could strand an installed trigger. Both the caller gate and mutateOwnedTrigger refused every mutation on an unrecognized schema, so Protect was correctly blocked and Disable was blocked too - leaving an active OpenCodex trigger with no in-product way to remove it. Installing into an unknown schema stays refused; removing our own trigger is now always allowed. mkdirSync applies its mode only when it creates the directory, so a lock directory left by an earlier build kept its old permissions and every mutation failed unsafe_path permanently. When we own the directory we now chmod it to 0700 and re-read to confirm, failing closed if the filesystem ignores it. --- src/codex/log-guard/lock.ts | 19 ++++++++++++++++++- src/codex/log-guard/protection.ts | 20 ++++++++++++++++---- tests/codex-log-guard-protection.test.ts | 20 ++++++++++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/codex/log-guard/lock.ts b/src/codex/log-guard/lock.ts index a3baf684c1..50a6834e56 100644 --- a/src/codex/log-guard/lock.ts +++ b/src/codex/log-guard/lock.ts @@ -69,9 +69,26 @@ function resolveLogGuardLockDatabase( } if (process.platform !== "win32") { const uid = process.getuid?.(); - if (uid === undefined || dirStat.uid !== uid || (dirStat.mode & 0o777) !== 0o700) { + // `mkdirSync` applies `mode` only when it CREATES the directory, so a + // directory left by an earlier build (or a permissive umask on a older + // version) keeps its old permissions forever and every Log Guard mutation + // fails `unsafe_path` with no in-product way out. Tighten it ourselves when + // we own it, and only refuse if that cannot be achieved. + if (uid === undefined || dirStat.uid !== uid) { throw new CodexUserIdentityRefusal("Codex Log Guard lock directory is not private to the effective user."); } + if ((dirStat.mode & 0o777) !== 0o700) { + try { + chmodSync(locksDir, 0o700); + } catch { + throw new CodexUserIdentityRefusal("Codex Log Guard lock directory is not private to the effective user."); + } + // Re-read rather than trusting the chmod: a filesystem that ignores mode + // bits must still fail closed. + if ((lstatSync(locksDir).mode & 0o777) !== 0o700) { + throw new CodexUserIdentityRefusal("Codex Log Guard lock directory is not private to the effective user."); + } + } } return join(locksDir, `${codexLogGuardLockDigest(canonicalCodexHome, canonicalLogsDbPath)}.sqlite`); diff --git a/src/codex/log-guard/protection.ts b/src/codex/log-guard/protection.ts index a3a40c4e80..5955000180 100644 --- a/src/codex/log-guard/protection.ts +++ b/src/codex/log-guard/protection.ts @@ -303,9 +303,14 @@ function mutateOwnedTrigger( db.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); transactionOpen = true; if (!exactCurrentSchema(db)) { - db.exec("ROLLBACK"); - transactionOpen = false; - return { ok: false, error: "unsupported_schema" }; + // Same reasoning as the caller's gate: installing into an unrecognized + // schema is refused, but removing a trigger we installed ourselves stays + // available so a schema upgrade cannot strand it. + if (mode !== "off") { + db.exec("ROLLBACK"); + transactionOpen = false; + return { ok: false, error: "unsupported_schema" }; + } } const rows = queryReservedTriggers(db); @@ -396,7 +401,14 @@ function performMutation( const codexHome = deps.codexHome ?? getCodexHome(); const inspection = inspectCodexLogs({ codexHome }); const databasePath = resolveCodexLogsDbPath({ codexHome }); - if (inspection.capabilities.protection.state !== "supported") { + // Removal must not be gated on the schema still being recognized. A Codex + // upgrade that changes the logs schema would otherwise strand an installed + // trigger: Protect is refused (correctly), but so is Disable, leaving the + // user with an active OpenCodex trigger and no in-product way to remove it. + // Installing into an unknown schema stays refused; taking our own trigger + // back out is always allowed. + const removingProtection = typeof requestedMode !== "function" && requestedMode === "off"; + if (!removingProtection && inspection.capabilities.protection.state !== "supported") { return { ok: false, error: "unsupported_schema" }; } if (!databasePathIsSafe(databasePath)) return { ok: false, error: "unsafe_path" }; diff --git a/tests/codex-log-guard-protection.test.ts b/tests/codex-log-guard-protection.test.ts index b98df62379..9e49d8dab8 100644 --- a/tests/codex-log-guard-protection.test.ts +++ b/tests/codex-log-guard-protection.test.ts @@ -307,4 +307,24 @@ describe("Codex Log Guard protection", () => { expect(protectCodexLogs("compat", deps)).toEqual({ ok: false, error: "config_write_failed" }); expect(triggers(databasePath)).toEqual([]); }); + test("Disable still works after the Codex schema moves out from under us", async () => { + // Protect is correctly refused on an unrecognized schema, but gating Disable + // the same way stranded an installed trigger: the user kept an active + // OpenCodex trigger with no in-product way to remove it. + const { codexHome, databasePath } = fixture(); + const deps = testDeps(codexHome); + expect(protectCodexLogs("compat", deps).ok).toBe(true); + expect(triggers(databasePath).map(row => row.name)).toEqual(["opencodex_log_guard_compat_v1"]); + + // Simulate a Codex upgrade adding a column, so the exact-schema check fails. + const db = new Database(databasePath); + db.exec("ALTER TABLE logs ADD COLUMN future_field TEXT"); + db.close(); + + // Installing into the unknown schema stays refused... + expect(protectCodexLogs("quiet", deps)).toEqual({ ok: false, error: "unsupported_schema" }); + // ...but taking our own trigger back out is always available. + expect(unprotectCodexLogs(deps).ok).toBe(true); + expect(triggers(databasePath)).toEqual([]); + }); });