JuliusBrussee/caveman · error

opencode inline configuration must be a JSON object

Error message

opencode inline configuration must be a JSON object

What it means

After successfully parsing OpenCode's existing inline configuration as JSONC, Caveman requires the result to be a JSON object (a plain record of keys) because it deep-merges its routing fields into it. If the parsed value is an array, string, number, or null, there is nothing to merge into and the configuration layer would be structurally invalid for OpenCode, so the wrapper throws with this message.

Solutions

  1. Rewrite the inline config so its top level is a JSON object: {"model": ..., "mcpServers": {...}} rather than an array or scalar.
  2. Validate with node -e "const v=JSON.parse(process.env.OPENCODE_CONFIG); if (v===null || typeof v!=='object' || Array.isArray(v)) process.exit(1)" before launching.
  3. Unset the variable and configure OpenCode via its config file if the inline value cannot be reshaped.

Example fix

// before
export OPENCODE_CONFIG='["server-a","server-b"]'   // array, not object
// after
export OPENCODE_CONFIG='{"mcpServers":{"a":{"command":"server-a"}}}'
Defensive patterns

Strategy: type-guard

Validate before calling

const parsed = JSON.parse(process.env.OPENCODE_CONFIG);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
  console.error("OpenCode inline config must be a JSON object at the top level.");
  process.exit(1);
}

Type guard

function isPlainObject(v) {
  return v !== null && typeof v === "object" && !Array.isArray(v);
}

Try / catch

try {
  caveman.launch({ agent: "opencode" });
} catch (e) {
  if (e instanceof Error && e.message === "opencode inline configuration must be a JSON object") {
    console.error("Wrap the inline config value in a top-level JSON object.");
  } else throw e;
}

Prevention

When it happens

Trigger: process.env[inj.env_var] contains JSONC that parses successfully but whose top-level value is not a plain object — e.g. '["a","b"]', '"just a string"', '42', or 'null'.

Common situations: Storing an array of MCP servers directly in the inline config var instead of an object with an mcpServers key; a script that serializes the wrong variable; hand-editing the config down to a bare value.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/a12644f31a518573. Report an issue: GitHub.

Appendix: source

Thrown at packages/cli/src/index.ts:9400

    let rendered = renderDeep(content, renderedGw, env, renderOptions);
    if (agent.id === "kilo" && mcpMode === "auto") {
      const ownedMcp = ownedMcpRegistration(agent.id, agentArgs);
      if (ownedMcp) {
        // Project exact owned registration into Kilo's highest user-controlled
        // layer. Route preflight rejects active org/managed sources that load
        // afterward, so no later known policy can silently disable recovery.
        rendered = deepMerge(rendered, { mcp: { caveman: kiloMcpEntry(ownedMcp) } });
      }
    }
    if (agent.id === "opencode" && process.env[inj.env_var]) {
      // OpenCode treats inline JSONC as its own configuration layer. Replacing
      // that layer loses the user's model, account, permissions and MCP servers.
      // Preserve native {env:...}/{file:...} references for OpenCode to resolve
      // in the same context; only our routing fields take precedence.
      let original: unknown;
      try { original = parseJsonc(process.env[inj.env_var]!); }
      catch { throw new Error("cannot preserve opencode inline configuration; launching with the original configuration is required"); }
      if (!isPlainObject(original)) throw new Error("opencode inline configuration must be a JSON object");
      rendered = deepMerge(original, rendered);
    }
    env[inj.env_var] = JSON.stringify(rendered);
  } else if (inj.method === "config-file") {
    try {
      applyConfigFileInjection(env, agent, inj, renderedGw, gw, mcpMode, agentArgs, upstreams);
    } catch (e) {
      // OpenClaw ignores the generic base-URL union. Config injection is its only
      // provider redirect, so a failed/missing route must abort the wrapped path;
      // spawnWrapped then launches direct with an explicit warning.
      if (agent.id === "openclaw" || agent.id === "qwen") throw e;
      process.stderr.write(`caveman: ${agent.id} config-file injection failed; using generic env wrap (${(e as Error).message})\n`);
    }
  }
  // Qwen 0.22 discovers MCP servers in the background by default. Its first
  // request can therefore omit caveman_retrieve even though our durable marker
  // says recovery is installed. Proxy compression must never outrun recovery,
  // so marker-backed Qwen wraps use Qwen's compatibility switch to finish MCP

View on GitHub (pinned to 3ee70a1026)