JuliusBrussee/caveman · error

env must be a JSON object; refusing to overwrite it

Error message

${settingsPath} env must be a JSON object; refusing to overwrite it

What it means

`claudeNativeMutations()` edits Claude's settings.json to inject Caveman env routing. Before writing, it validates that an existing `env` key is a plain JSON object; if `env` exists but is a non-object (string, number, array, null), the CLI refuses to overwrite it to avoid destroying user data. The user must fix the settings file manually.

Solutions

  1. Open the settings file reported in the message and change `env` to a JSON object (e.g. {} or your key/value map)
  2. Back up the file first: cp settings.json settings.json.bak, then fix and re-run the command
  3. If env holds meaningful data in a wrong shape, convert it to key/value form instead of deleting it
  4. Re-run `caveman setup` after fixing the file

Example fix

// before: ~/.claude/settings.json
{ "env": "ANTHROPIC_BASE_URL=https://example.com" }
// after
{ "env": { "ANTHROPIC_BASE_URL": "https://example.com" } }
Defensive patterns

Strategy: type-guard

Validate before calling

// Before running setup, validate the Claude settings env shape:
const s = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
if (s.env !== undefined && (typeof s.env !== 'object' || s.env === null || Array.isArray(s.env))) {
  console.error(`Fix ${settingsPath}: env must be a JSON object`);
}

Type guard

function isValidEnvObject(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

Try / catch

try {
  await cavemanSetup(['claude']);
} catch (e) {
  if (String(e.message).endsWith('env must be a JSON object; refusing to overwrite it')) {
    // fix or back up settings.json manually, then retry
  }
  throw e;
}

Prevention

When it happens

Trigger: Running a native Claude setup/mutation command when the parsed settings document has `env` defined but not a non-null plain object (`typeof settings.env !== "object" || settings.env === null || Array.isArray(settings.env)`).

Common situations: Hand-edited ~/.claude/settings.json where env was accidentally changed to a string or array; a tool or script wrote env as a flat string; corrupted settings from a failed merge; null env from a template placeholder that was never filled.

Related errors


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

Appendix: source

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

    const invocation = portableInvocation(binary, ["--version"]);
    const out = spawnSync(invocation.command, invocation.args, { encoding: "utf8", timeout: 3000 });
    if (out.error) return { binary, launchable: false, version: null, error: boundedHookString(out.error.message, 240) ?? "version_probe_failed" };
    const value = `${out.stdout ?? ""} ${out.stderr ?? ""}`.trim();
    if (out.status !== 0) return { binary, launchable: false, version: value ? value.slice(0, 160) : null, error: `version_probe_exit_${out.status ?? "unknown"}` };
    return { binary, launchable: true, version: value ? value.slice(0, 160) : null, error: null };
  } catch {
    return { binary, launchable: false, version: null, error: "version_probe_failed" };
  }
}

function detectedAgentVersion(agent: AgentProfile): string | null { return nativeHostProbe(agent).version; }

function claudeNativeMutations(gw: string, mcpBinary: string): NativeMutation[] {
  const settingsPath = claudeSettingsPath();
  const settingsBefore = fileBytes(settingsPath);
  const settings = parseJsonFileObject(settingsPath, settingsBefore);
  if (settings.env !== undefined && (typeof settings.env !== "object" || settings.env === null || Array.isArray(settings.env))) {
    throw new Error(`${settingsPath} env must be a JSON object; refusing to overwrite it`);
  }
  assertNativeHooksShape(settingsPath, settings, "claude");
  const env = settings.env && typeof settings.env === "object" && !Array.isArray(settings.env)
    ? settings.env as Record<string, unknown>
    : {};
  const route = appendUrlPath(gw, "/w/claude");
  const previousRoute = env.ANTHROPIC_BASE_URL;
  env.ANTHROPIC_BASE_URL = route;
  // Routing through the local proxy makes ANTHROPIC_BASE_URL non-first-party in
  // Claude Code's eyes, which silently drops the 1M context / ~600k
  // auto-compact window to 200k (#865). When the proxy's anthropic upstream is
  // verifiably api.anthropic.com, assert first-party through Claude Code's own
  // escape hatch. Never touches a value the user already carries in settings.
  const assumeFirstParty = env[CLAUDE_ASSUME_FIRST_PARTY_ENV] === undefined
    && wrapMode(gw) === "local" && proxyAnthropicUpstreamIsFirstParty();
  if (assumeFirstParty) env[CLAUDE_ASSUME_FIRST_PARTY_ENV] = "1";
  // Native routing points every later turn at the same loopback gateway, so it
  // needs the wrap path's proxy exemption too (#1001). Never clobber a value the

View on GitHub (pinned to 3ee70a1026)