Skip to content

Duplicate [hooks.state.*] keys in config.toml on Windows — findTomlSection() does not normalize TOML quoting #64

@Quarkykoala

Description

@Quarkykoala

Summary

[hooks.state.*] entries in codex's config.toml keep getting duplicate keys on Windows, causing codex to fail loading its config with a "duplicate key" / "Cannot declare ... twice" parse error. This recurs every time codex's Rust runtime and the omo-codex TypeScript installer both write hook trust state to the same file using different TOML quoting styles for Windows paths.

Environment

  • LazyCodex version: omo-codex 4.12.1 (via oh-my-openagent)
  • Codex version: 0.141.0
  • OS: Windows 11 (win32)
  • Install method: npx lazycodex-ai install + Orca codex-runtime-home
  • Relevant config: C:\Users\lenovo\AppData\Roaming\orca\codex-runtime-home\home\config.toml

Repository Decision

  • Target repository: code-yeongyu/lazycodex
  • Why this belongs there: The defect is in the omo-codex installer's TOML section editor (findTomlSection()), which is distributed via lazycodex/oh-my-openagent. The installer produces double-quoted [hooks.state.*] keys that collide with codex's own single-quoted keys.
  • LazyCodex evidence (runtime + source):
    • packages/omo-codex/src/install/codex-config-plugins.ts line 28: ensureHookTrusted() builds header as hooks.state.${JSON.stringify(state.key)}JSON.stringify always produces double-quoted basic strings with escaped backslashes (e.g., "C:\\Users\\lenovo\\...\\hooks.json:session_start:0:0").
    • packages/omo-codex/src/install/toml-section-editor.ts line 14: findTomlSection() does exact text matching (if (trimmed === headerLine)) — it cannot find a single-quoted section when searching for a double-quoted one (or vice versa).
    • When findTomlSection() returns null, ensureHookTrusted() calls appendBlock() → a new [hooks.state.*] block is appended → duplicate key.
  • Upstream Codex source evidence: Codex's Rust runtime uses the toml_edit crate, which serializes Windows paths as single-quoted literal strings (e.g., 'C:\Users\lenovo\...\hooks.json:session_start:0:0') — no escaping needed, more readable. Both quoting styles resolve to the same TOML key string.

Reproduction

  1. On Windows, install omo-codex via npx lazycodex-ai install — this writes [hooks.state."C:\\Users\\...\\hooks.json:session_start:0:0"] (double-quoted) to config.toml.
  2. Launch codex and approve the Orca hook when prompted — codex's Rust runtime writes [hooks.state.'C:\Users\...\hooks.json:session_start:0:0'] (single-quoted) to the same config.toml.
  3. Run npx lazycodex-ai install again (or the bootstrap setup re-runs on upgrade) — ensureHookTrusted() searches for the double-quoted header, doesn't find it (because the single-quoted version exists), appends a new double-quoted block → duplicate key.
  4. Next codex launch fails with: Error loading config.toml: ...:duplicate key or Cannot declare ('hooks', 'state', '...') twice.

Expected Behavior

The omo-codex installer should recognize existing [hooks.state.*] entries regardless of quoting style (single-quoted literal vs double-quoted basic) and update them in place rather than appending duplicate blocks.

Actual Behavior

findTomlSection() does exact string comparison on the header line, so it fails to match entries with different quoting styles. ensureHookTrusted() appends a new block, creating a duplicate key that causes codex to fail loading its config.

Evidence

Backup file timeline (from C:\Users\lenovo\AppData\Roaming\orca\codex-runtime-home\home\):

Timestamp File Orca hook quoting Duplicate?
2026-06-20 20:03 config.toml.bak-lazycodex-20260620-200449 Double-quoted only No
2026-06-20 20:14 config.toml.bak-lazycodex-final-20260620-201452 Double-quoted only No
2026-06-20 22:11 config.toml.bak-lazycodex-demo-20260620-221148 Double-quoted only No
2026-06-21 09:08 config.toml.bak-doctor-fix-20260621-091225 Both single + double Yes
2026-06-21 13:26 config.toml.bak Both single + double Yes

The duplicate appeared between June 20 22:11 and June 21 09:08 — after codex ran a session and its Rust runtime added single-quoted entries alongside the installer's double-quoted ones.

Parse error (from tomllib.load() on the duplicated file):

Cannot declare ('hooks', 'state', 'C:\\Users\\lenovo\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json:session_start:0:0') twice (at line 353, column 113)

Source code confirming the defect:

packages/omo-codex/src/install/toml-section-editor.ts:

export function findTomlSection(config: string, header: string): TomlSection | null {
  const headerLine = `[${header}]`
  const lines = config.match(/[^\n]*\n?|$/g) ?? []
  let offset = 0
  let start = -1
  for (const line of lines) {
    if (line.length === 0) break
    const trimmed = line.trim()
    if (start === -1) {
      if (trimmed === headerLine) start = offset  // EXACT TEXT MATCH — fails on quote-style mismatch
    } else if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
      return { start, end: offset, text: config.slice(start, offset) }
    }
    offset += line.length
  }
  // ...
}

packages/omo-codex/src/install/codex-config-plugins.ts:

export function ensureHookTrusted(config: string, state: TrustedHookState): string {
  const header = `hooks.state.${JSON.stringify(state.key)}`  // JSON.stringify → double-quoted
  const section = findTomlSection(config, header)
  if (!section) return appendBlock(config, `[${header}]\ntrusted_hash = ${JSON.stringify(state.trustedHash)}\n`)  // appends duplicate
  return replaceOrInsertSetting(config, section, "trusted_hash", JSON.stringify(state.trustedHash))
}

Root Cause

findTomlSection() in toml-section-editor.ts compares TOML header lines as exact strings. TOML allows both single-quoted (literal) and double-quoted (basic) strings for table keys — 'C:\Users\...' and "C:\\Users\\..." are semantically identical keys. The function does not normalize quoting before comparing, so when the installer searches for a double-quoted header and the existing entry is single-quoted (written by codex's Rust runtime), it fails to find the section and appends a duplicate block.

Proposed Fix

Normalize TOML quoting in findTomlSection() before comparing header lines. Two approaches:

Option A (minimal, recommended): Parse the quoted key from both the search header and the file line, normalize to the actual string value, and compare values instead of text. This mirrors what a TOML parser does internally:

function normalizeTomlKey(quoted: string): string {
  quoted = quoted.trim()
  if (quoted[0] === "'" && quoted[quoted.length - 1] === "'") return quoted.slice(1, -1)
  if (quoted[0] === '"' && quoted[quoted.length - 1] === '"') {
    // unescape basic string: \\ → \, \" → ", etc.
    return JSON.parse(quoted)
  }
  return quoted
}

Then in findTomlSection(), extract the key portion from both headers and compare normalized values.

Option B (broader): Use a real TOML parser (e.g., @iarna/toml) to parse the config into an object, modify the hooks.state map, and re-serialize. This eliminates all text-matching fragility but is a larger change.

Also affected: parseJsonString() in codex-config-toml-sections.ts (if it exists) uses JSON.parse() which rejects single-quoted strings — the cleanup path (isManagedHookStateHeader) would also fail to recognize single-quoted hooks.state entries.

Verification Plan

  • Write a unit test that creates a config with single-quoted [hooks.state.'C:\Users\...\hooks.json:session_start:0:0'], then runs ensureHookTrusted() with the same key — it should update the existing block, not append a duplicate.
  • Write a unit test with double-quoted entries and verify the same behavior.
  • Run npx lazycodex-ai install twice on a Windows config that has single-quoted entries — verify no duplicates are created.
  • Verify the output config parses cleanly with both Python tomllib and Rust toml crate.

This issue or PR was generated by LazyCodex.
Tag: lazycodex-generated

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions