JuliusBrussee/caveman · error

cave_gateway_required_for_locked_plan

cave_gateway_required_for_locked_plan

Error message

cave_gateway_required_for_locked_plan: Cave Build execution routes through the Caveman gateway; run unlocked for observe-only

What it means

Thrown by route resolution when RunOptions.cave is explicitly "off" while a locked build or candidate plan is attached to the run. Locked-plan execution must route through the Caveman gateway, because billing/evidence (the x-cave-* headers and account key) are only honest when the gateway proxies the request; observe-only passthrough would silently under-claim, so the run fails closed instead of degrading. Unlocked runs with cave "off" are fine and simply report RunResult.mode "observe-only".

Source

Thrown at packages/agent/src/runtime.ts:5094

    billingProofRequired?: boolean;
  },
  planPresent: boolean,
): Promise<ResolvedCaveRoute> {
  if (options.caveRoute !== undefined) {
    if (!options.billingProofRequired || !options.caveRoute.useGateway ||
        options.caveRoute.providerBilling === "managed" ||
        options.caveRoute.providerBilling === "byok") {
      return options.caveRoute;
    }
    const identity = await gatewayIdentity(gatewayURL, options.fetch ?? globalThis.fetch);
    return {
      ...options.caveRoute,
      providerBilling: identity?.providerBilling ?? "unknown",
    };
  }
  if (options.cave === "off") {
    if (planPresent) {
      throw new Error(
        "cave_gateway_required_for_locked_plan: Cave Build execution routes through the Caveman gateway; run unlocked for observe-only",
      );
    }
    return { useGateway: false, providerBilling: "unknown" };
  }
  if (options.ensureRuntime === false) {
    try {
      const url = new URL(gatewayURL);
      if (isLoopbackHostname(url.hostname)) {
        const providerBilling = options.billingProofRequired
          ? (await gatewayIdentity(gatewayURL, options.fetch ?? globalThis.fetch))?.providerBilling ??
            "unknown"
          : "unknown";
        return { useGateway: true, providerBilling };
      }
      // Remote gateways still have to prove Caveman identity. `ensureCaveRuntime`
      // never starts a process for non-loopback URLs; it only enforces HTTPS and
      // performs the content-blind ownership handshake.

View on GitHub (pinned to 766dce6b13)

Solutions

  1. Remove cave:'off' and let the default ensureRuntime auto-start/attach the loopback Caveman gateway (caveman setup --install && caveman start once to install the runtime)
  2. Start the gateway before the run (caveman start) and verify with caveman doctor that locked-execution readiness is true
  3. If you intentionally cannot use a gateway, drop the locked plan / candidate plan from the call and run unlocked (observe-only) instead
  4. In the coding-session surface, this exact error earns the built-in single retry without the plan — rely on that only if observe-only is acceptable

Example fix

// before
await run(agent, { plan: lockedPlan, cave: 'off' }); // throws cave_gateway_required_for_locked_plan

// after — option 1: let the runtime ensure the loopback gateway (default)
await run(agent, { plan: lockedPlan });

// after — option 2: intentionally unlocked, observe-only
await run(agent, { cave: 'off' });
Defensive patterns

Strategy: fallback

Validate before calling

async function gatewayReady(gatewayURL, fetchImpl = fetch) {
  try {
    const res = await fetchImpl(gatewayURL.replace(/\/$/, '') + '/health/ready');
    return res.ok; // Caveman identity implied by 200 on /health/ready
  } catch { return false; }
}
// before a locked run:
if (!(await gatewayReady(gatewayURL))) await startGateway();

Type guard

function runsLockedPlan(options) {
  return Boolean(options.plan ?? options.build ?? options.lockedBuild);
}

Try / catch

try {
  await run(agent, options);
} catch (error) {
  if (error instanceof Error && error.message.startsWith('cave_gateway_required_for_locked_plan')) {
    // same policy as the coding-session surface: exactly one retry, unlocked (observe-only)
    return run(agent, { ...options, plan: undefined });
  }
  throw error;
}

Prevention

When it happens

Trigger: Calling the agent run API with a locked plan / candidate plan present (planPresent true) while options.cave === "off" — e.g. run(agent, { plan: lockedPlan, cave: 'off' }). The check fires before any probe: cave is off AND a plan is attached.

Common situations: CI pipelines that disable the gateway with cave:'off' but still execute a compiled plan; developers copying an options object that pins cave:'off' from an observe-only experiment and then attaching a locked build; an env/config toggle that maps to cave off while the build artifact is auto-loaded.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@766dce6b13 (2026-08-18). Data as JSON: /api/errors/a8b428f29fa75d63. Report an issue: GitHub.