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
- 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)
- Start the gateway before the run (caveman start) and verify with caveman doctor that locked-execution readiness is true
- If you intentionally cannot use a gateway, drop the locked plan / candidate plan from the call and run unlocked (observe-only) instead
- 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
- Never combine cave:'off' with a locked plan in CI configs — assert one or the other in a config lint
- Run caveman doctor as a CI step; treat locked-execution readiness false as a skipped-not-failed locked lane
- Install/ensure the runtime once per machine (caveman setup --install) so the default ensureRuntime path works
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
- cave_mastra_max_steps_invalid
- cave_budget_denomination_ambiguous
- cave_budget_max_invalid
- cave_budget_on_exhausted_invalid
- caveman agent: subagent maxCostUsd must be positive
AI-assisted analysis of JuliusBrussee/caveman@766dce6b13 (2026-08-18).
Data as JSON: /api/errors/a8b428f29fa75d63.
Report an issue: GitHub.