{"record":{"id":"15984c828a064c50","repo":"JuliusBrussee/caveman","slug":"merged-config-is-not-json","errorCode":null,"errorMessage":"merged config is not JSON","messagePattern":"merged config is not JSON","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/cli/src/index.ts","lineNumber":9157,"sourceCode":"      rawOverlay = deepMerge(rawOverlay, {\n        mcp: { excluded: [...new Set([...preserved, \"caveman\"])] },\n      });\n    }\n  }\n  const overlay = (mcpMode === \"auto\" ? rawOverlay : withoutCavemanMcpServer(rawOverlay as JsonObject)) as JsonObject;\n  const merged = deepMerge(baseConfig, overlay) as JsonObject;\n  if (agent.id === \"qwen\") {\n    // Qwen treats these as whole-object REPLACE settings. Our generic merge is\n    // intentionally conservative for other agents, but retaining a sibling\n    // provider here would make it selectable at runtime outside /w/qwen.\n    for (const key of [\"modelProviders\", \"providerProtocol\"] as const) {\n      const value = overlay[key];\n      if (value === undefined) throw new Error(`Qwen routed profile is missing ${key}`);\n      merged[key] = cloneJsonValue(value);\n    }\n  }\n  const rendered = JSON.stringify(merged, null, 2);\n  if (rendered === undefined) throw new Error(\"merged config is not JSON\");\n  const outDir = mkdtempSync(join(tmpdir(), \"caveman-wrap-\"));\n  const outPath = join(outDir, `${agent.id}.json`);\n  writeFileSync(outPath, rendered + \"\\n\", { mode: 0o600 });\n  wrapTempDirs.add(outDir);\n  env[inj.env_var] = outPath;\n}\n\nfunction cleanupWrapTempDirs() {\n  for (const dir of [...wrapTempDirs]) {\n    try {\n      rmSync(dir, { recursive: true, force: true });\n    } catch {\n      // Temp cleanup is best-effort; wrap must never fail after child exit.\n    } finally {\n      wrapTempDirs.delete(dir);\n    }\n  }\n}","sourceCodeStart":9139,"sourceCodeEnd":9175,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/3ee70a102609e550bd2e68004bf5990a9341c851/packages/cli/src/index.ts#L9139-L9175","documentation":"After merging the base and overlay configs, the wrapper serializes the merged object with JSON.stringify and asserts the result is not undefined. JSON.stringify returns undefined only for non-JSON values (undefined, functions, symbols) at the top level, so this throw is an internal invariant check that the merge produced a JSON-serializable object.","triggerScenarios":"The merged config object is not JSON-serializable — practically only if the merge logic changes to return undefined (e.g. merging undefined into a plain object path or a code regression), since profile values pass through cloneJsonValue.","commonSituations":"Almost exclusively hit during development/modification of the wrapper itself; end users should not see it unless running a patched or corrupted CLI build.","solutions":["Check that the base and overlay config objects passed into the merge are plain JSON values before merging.","Ensure cloneJsonValue is used for all injected values so no functions/symbols/undefined reach the merged object.","If you patched the CLI, revert the change and rebuild; this path is an invariant, not a user-config error.","Report the exact profile inputs that reproduce it as a CLI bug if it occurs on a stock build."],"exampleFix":"// before\nconst merged = { ...base, ...overlay };\n// after (guard non-JSON values)\nconst merged = { ...base, ...cloneJsonValue(overlay) };","handlingStrategy":"type-guard","validationCode":"function isJsonSerializable(v: unknown): boolean {\n  return v !== undefined && typeof v !== \"function\" && typeof v !== \"symbol\";\n}\nif (!isJsonSerializable(merged)) throw new Error(\"merged config must be a JSON value before stringify\");","typeGuard":"function isPlainJsonValue(v: unknown): v is string | number | boolean | null | object {\n  if (v === null) return true;\n  const t = typeof v;\n  return t === \"string\" || t === \"number\" || t === \"boolean\" || (t === \"object\" && !Array.isArray(v) === true || Array.isArray(v));\n}","tryCatchPattern":"try {\n  wrapAgent(agent, env);\n} catch (e) {\n  if ((e as Error).message === \"merged config is not JSON\") {\n    // invariant violation: inspect base/overlay inputs for non-JSON values\n  }\n}","preventionTips":["Always deep-clone profile values with cloneJsonValue before merging.","Add a unit test asserting the merged object survives JSON.parse(JSON.stringify(...)).","Never assign functions, symbols, or undefined into merged config objects."],"tags":["internal","json-serialization","invariant"],"backgroundTag":"json-serialization-failed","analyzedSha":"3ee70a102609e550bd2e68004bf5990a9341c851","analyzedAt":"2026-09-20T15:53:39.229Z","contentChangedAt":"2026-09-20T15:53:39.229Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}