Skip to content

[Bug] ocx config set / ocx config import bypass the protected 3-way reconcile and can clobber concurrent disk / hand edits #1838

Description

@trungtaottn

[Bug] ocx config import silently deletes already-saved hand edits (deterministic); ocx config set/unset can drop a concurrent edit (race)

Issue #1838, rewritten from a real user's live experience.

Client or integration

Codex CLI (ocx) — config import, config set, config unset.

Summary

As a user, I hand-edit ~/.opencodex/config.json (I set streamMode, or tweak emptyCompletionRetry, or add a field of my own). Then I run a normal config command. My hand-written values get silently deleted, with no warning, and I only notice when I ocx config show later.

Two distinct ways this happens, in increasing order of seriousness:

  1. ocx config import file.json --yes destroys an edit that is already committed to disk — even with no concurrency. Import is a wholesale overwrite: whatever the import file does not contain, the CLI forgets. I proved this live: hand-edit → import a valid file lacking that field → edit gone. No race involved.
  2. ocx config set / unset uses a read-then-write against a stale snapshot. If something edits the file in the tiny window between the command's read and its write, that edit is dropped. This one is a real race and is narrower.

I expected config management commands to preserve the fields on disk they aren't touching. They don't.

Reproduction — deterministic import clobber (no concurrency needed)

Runs from a fresh temp HOME; CLI invoked via the bundled Bun runtime:

# 1. create a real config file
ocx config set emptyCompletionRetry true
#    -> writes ~/.opencodex/config.json with emptyCompletionRetry=true

# 2. hand-edit it like a normal user (this is committed to disk now)
#    add a field AND toggle an existing one
> edit config.json: add  "streamMode": "eager-relay",  "customFlag": "user-hand-edit"
>                   set  "emptyCompletionRetry": false

ocx config show --json | grep -E 'streamMode|emptyCompletionRetry|customFlag'
# streamMode: eager-relay ; emptyCompletionRetry: false ; customFlag: user-hand-edit   <- present

# 3. import a schema-valid file that happens to omit those edited fields
ocx config import ./fresh.json --yes       # fresh.json has emptyCompletionRetry=true, no streamMode/customFlag
# -> "Imported config from ./fresh.json. Restart or run ocx sync if needed."  (exit 0)

# 4. the on-disk hand edits are gone
ocx config get streamMode          # error: config path not found: streamMode
ocx config get customFlag          # error: config path not found: customFlag
ocx config get emptyCompletionRetry
# -> true     (my hand-toggled false has been reverted)

Observed output (live, Bun 1.3.14, v2.22.0):

  • config import ./fresh.json --yesImported config from ./fresh.json. Restart or run ocx sync if needed. (exit 0).
  • After (ocx config show disk state): streamMode, customFlag both deleted; emptyCompletionRetry back to true. All three hand edits destroyed.

There was no concurrency in this reproduction: the hand edits were fully written to disk before the command was invoked.

Caveat discovered live: import is not a blind overwrite — it validates the import file against the config schema first and refuses to write on an invalid input (an import file missing providers errored with schema_invalid and wrote nothing). The data loss happens when the import file is schema-valid but simply doesn't carry the user's extra field, which is exactly the common case.

Reproduction — set/unset race (confirmed with a concurrent writer)

ocx config set some.other.path value reads the current config snapshot, applies only its one path, and writes the whole object back. An edit that lands between the snapshot read and the write is overwritten by the stale snapshot. I reproduced this live by running ocx config set port 10100 in a loop while a separate process continuously rewrote an unrelated field (a monotonic counter) in the same file: 34 of 51 rounds the counter was reverted to a stale lower value — i.e. set wrote a snapshot that predated the concurrent edit and destroyed it. Without an aggressive concurrent writer the window is narrow and often passes, which is why a plain hand-edit-then-set usually looks safe and this hides.

Expected vs Actual

Expected: ocx config set/unset rebase onto whatever is on disk right now, so they never destroy a concurrent hand edit. ocx config import (a deliberate overwrite) diffs its result against the current on-disk config and warns about keys it is about to drop before saving.

Actual:

  • import silently overwrites the whole file; any key the import file lacks is dropped with no diff/warning. Reproduced deterministically.
  • set/unset write a whole-object snapshot taken a moment earlier; a concurrent disk edit in between is lost. Race reproduced live (34/51 counter reverts) under a concurrent writer.

Evidence — what I actually ran

Live CLI (throwaway copy /tmp/opencodex-cli, Bun 1.3.14, source v2.22.0 d9de895):

  • Baseline: ocx config set emptyCompletionRetry true → file written.
  • Hand-edit of streamMode, customFlag, emptyCompletionRetry committed to disk.
  • Schema-valid ocx config import fresh.json --yes (file omitting those fields) → exit 0, then ocx config show confirmed all three edits gone. ─ deterministic, no concurrency.
  • ocx config set port 10100 under a high-frequency concurrent writer with a monotonic disk counter → 51 valid rounds, 34 lost-update reverts of the counter (monotonic counter regressed to a lower snapshot value = stale snapshot written over a newer concurrent edit).

Source facts (read, not inferred):

  • src/cli/config-command.ts:133config set/unset snapshot readConfigDiagnostics().config.
  • src/cli/config-command.ts:145 — set/unset persist via raw saveConfig(config) (whole-object write, no disk rebase).
  • src/cli/config-command.ts:178saveConfig(validate(loadInput(path))); loadInput (:67) reads only the import file, never the current config → import is structural whole-file overwrite.
  • src/config.ts:2766 saveConfig — only custom-model-catalog reconciliation, then persistConfigUnlocked; no general 3-way on-disk rebase.
  • src/config.ts:3185 saveConfigPreservingClaudeCode — the rebasing writer (re-reads disk, reconciles); used by server-side writers, not by these CLI config commands.
  • src/server/index.ts:562 — baseline is armed at server start.
  • Related open [Bug] ocx sync overwrites hand-edited config.json from stale server memory (GLM-5.3 tune lost); 2.21.0 save path fixed it but sync path question remains #1802 — same clobber family on the service path; its fix covered service saves, not these CLI commands.

Expected behavior (what I'd ask of a fix)

  • ocx config set/unset should merge onto the current on-disk config before writing (like the server-side rebasing writer), so a concurrent hand edit is never dropped.
  • ocx config import should diff its candidate against the current on-disk config and warn about (or require acking) keys it is about to remove, since it is a destructive whole-file overwrite.

Version

2.22.0 (main d9de895)

Operating system

Linux

Checks

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcliCLI, config inject, packaging flags

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions