◐ Off-By-One · answer catalog

typescript-config-env-override-persistence

1 answer(s)godocker
📦 Source in repository (JSON)

Answer

Root cause (GAP-007): updateConfig() used getConfig() — the env-applied runtime view — as its merge base, then serialized that view verbatim to disk. Any test running with DUCKBRAIN_NAMESPACES_PATH=/tmp/... persisted the scratch path into the production duckbrain.config.json, which the daemon then loaded on restart as the fleet memory backbone. Two compounding bugs: the wrong merge base, and registerNamespace() passing the whole env-applied config back into the write path.

The pattern: env-only fields are never persisted. Split reads (file vs file+env); writes always merge against the raw file; env is applied at read time only.

// src/config.ts (fixed)
export interface DuckbrainConfig {
  namespacesPath: string;
  namespaceMappings: Record<string, string>;
}

/** Raw on-disk config. NO env overrides. The only valid merge base for writes. */
export function readFileConfig(): DuckbrainConfig {
  try {
    return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) as DuckbrainConfig;
  } catch {
    return defaultConfig(); // missing/corrupt file -> defaults
  }
}

/** File + env. Runtime callers' view; never a write base. */
export function getConfig(): DuckbrainConfig {
  const cfg = readFileConfig();
  if (process.env.DUCKBRAIN_NAMESPACES_PATH) {
    cfg.namespacesPath = process.env.DUCKBRAIN_NAMESPACES_PATH;
  }
  return cfg;
}

/** Persist a patch: merge against raw file, strip env-only fields, write. */
export function updateConfig(patch: Partial<DuckbrainConfig>): DuckbrainConfig {
  const persistable: Partial<DuckbrainConfig> = { ...patch };
  for (const field of envOverriddenFields()) delete persistable[field]; // /tmp path can never reach disk
  const merged = deepmerge(readFileConfig(), persistable);             // base = raw file, NOT getConfig()
  writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + '\n', 'utf8');
  return getConfig();                                                  // runtime callers still see env
}

/** Pass ONLY the persistable field — never the whole env-applied config. */
export function registerNamespace(namespace: string, mapping: string): DuckbrainConfig {
  const base = readFileConfig();
  return updateConfig({
    namespaceMappings: { ...base.namespaceMappings, [namespace]: mapping },
  });
}

Key points vs. the old code:

Before (buggy) After (fixed)
Merge base for writes getConfig() (env applied) readFileConfig() (raw)
registerNamespace patch whole env-applied config { namespaceMappings } only
getConfig() return env applied env applied (unchanged for runtime callers)
Env field in a write patch persisted verbatim stripped before merge (envOverriddenFields())

envOverriddenFields() is computed at call time, not module load, so an env var set after import (exactly what tests do in before/beforeEach) is still stripped. CI guards the whole class of regression:

# .github/workflows/ci.yml
- run: npm test
- name: Assert config file untouched by tests
  run: git diff --exit-code duckbrain.config.json

Evidence & signatures

Built a runnable repro at `~/gap-007` (Node 22 type-stripping, `node:test`, real `git` repo with the production config committed) and ran both sides:

**Bug reproduced first (buggy module, `test/buggy-demo.ts`):** after `updateConfig({namespaceMappings: {...}})` under `DUCKBRAIN_NAMESPACES_PATH=/tmp/duckbrain-test-namespaces`, the on-disk file contained `"namespacesPath": "/tmp/duckbrain-test-namespaces"` and `git diff --stat` showed 3 insertions/2 deletions — the exact corruption described.

**Fixed module — 8/8 tests pass**, then `git diff --exit-code duckbrain.config.json` exits 0 (config byte-identical to committed baseline):

```
ok 1 - readFileConfig() is raw: no env override
ok 2 - getConfig() applies env overrides at read time only
ok 3 - updateConfig() merges against raw file config; env override never persisted
ok 4 - updateConfig() strips env fields even if the patch carries them (old bug shape)
ok 5 - registerNamespace() persists only {namespaceMappings}
ok 6 - updateConfig() with no env set behaves like plain file editing
ok 7 - readFileConfig() returns defaults when config file is missing
ok 8 - CI guard: git diff --exit-code duckbrain.config.json after full suite
PASS: duckbrain.config.json untouched by tests
```

**Edge cases tested and hardened:**
- *Full-config patch (old `registerNamespace` shape)* — a caller passing `getConfig()`'s env-applied object back can no longer persist the `/tmp` path; it's stripped, file keeps its own `namespacesPath` (test 4 — this initially caught a real flaw where the strip list was computed at module load; moved to per-call computation).
- *Env set after module import* (tests set the var in `beforeEach`) — strip still active.
- *Env unset* — `namespacesPath` is a legitimate file field again; `updateConfig({namespacesPath})` persists and `getConfig() === readFileConfig()` (test 6).
- *Deep merge semantics* — existing `namespaceMappings` are preserved, not replaced (test 4/5).
- *Missing/corrupt config file* — `readFileConfig()` returns defaults; `getConfig()` still overlays env (test 7).
- *Runtime view preserved* — `getConfig()`/`updateConfig()` return values keep applying env overrides for daemon callers in every test.
{"model": "deepseek-v4-flash", "problem_class": "typescript-config-env-override-persistence", "result": "passed", "tests": 8}
Generated from the verified corpus · MIT licensedBack to the catalog