Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit68eccaa

fix(loops): charge a measured floor so the spend cap cannot be opted out of

fix(loops): charge a measured floor so the spend cap cannot be opted out of

The monthly budget check itself was fine — invokeLoop short-circuits with
`budget_exceeded` once spend reaches monthlyBudgetCents. What fed it was
not. Spend came entirely from extractUsageFromStdout, i.e. from a `usage`
block printed by the snippet, and loop.sourceCode is arbitrary
user-supplied code any registered account can run — the file says exactly
that, a few lines above the env block.

So a snippet that never prints a usage block costed 0 cents. Its monthly
spend never advanced, so the cap could not stop it: unlimited
invocations, each burning up to LOOP_EXEC_TIMEOUT_MS of platform compute,
and platform Anthropic credits wherever HOSTED_LOOPS_PLATFORM_KEY=1. No
lying was required — omission was enough.

Cost is now max(self-reported, floor(measured duration)). Duration comes
from the executor, so a snippet cannot forge it. Self-reported usage
still wins when higher: it is the better estimate of real token spend and
nobody has an incentive to overstate it. The floor only removes the
option of reporting nothing. It applies to failed and timed-out runs too
— those consumed compute exactly like successful ones, and a snippet that
always fails must not be free to invoke forever.

This is a deliberate BILLING BEHAVIOUR CHANGE, not an internal one: runs
that previously cost 0 now cost at least the per-run minimum. Defaults are
1 cent per run plus a cent per 10s, which still leaves 500+ invocations a
month on the smallest $5 budget. Both knobs are env-tunable and read at
access time — HOSTED_LOOP_MIN_CENTS_PER_RUN and HOSTED_LOOP_MS_PER_CENT —
so the rate can be dialled, or the floor disabled with an explicit 0,
without a deploy.

Not attempted: verifying real token usage. That needs the platform to
proxy the Anthropic call rather than hand the key to user code, which is
an architectural change, not a patch. The floor bounds the exposure in
the meantime.

Both fault classes proven by injection: reverting to self-reported-only
fails 2 tests, neutering the floor rule fails 6.

Suite: 3504 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 29, 2026Parent: 489a5ac
2 files changed+216468eccaac679216acf9acc6649a6688962c01be44
2 changed files+216−4
Addedsrc/__tests__/hosted-loop-spend-floor.test.ts+166−0View fileUnifiedSplit
1/**
2 * Regression guard: a hosted loop's monthly budget must not be bypassable by
3 * simply declining to report usage.
4 *
5 * The cap itself was fine — invokeLoop short-circuits with `budget_exceeded`
6 * once monthly spend reaches monthlyBudgetCents. What fed it was not. Spend
7 * came entirely from extractUsageFromStdout, i.e. from a `usage` block
8 * printed by the snippet, and loop.sourceCode is arbitrary user-supplied
9 * code that any registered account can run (the file says so itself, above
10 * the env block).
11 *
12 * So a snippet that never prints a usage block costed 0 cents, its monthly
13 * spend never advanced, and the cap could never stop it: unlimited
14 * invocations, each burning up to LOOP_EXEC_TIMEOUT_MS of platform compute,
15 * and platform Anthropic credits wherever HOSTED_LOOPS_PLATFORM_KEY=1.
16 *
17 * The fix charges a floor derived from the executor's own measured duration,
18 * which the snippet cannot forge. Self-reported usage still wins when it is
19 * higher — it is the better estimate of real token spend and nobody has an
20 * incentive to overstate it. The floor only removes the option of reporting
21 * nothing.
22 *
23 * NOTE this is a deliberate billing behaviour change, not purely internal:
24 * runs that previously cost 0 now cost at least the per-run minimum. Both
25 * knobs are env-tunable and read at access time.
26 */
27
28import { describe, it, expect, afterEach } from "bun:test";
29import { readFileSync } from "node:fs";
30import { join } from "node:path";
31import {
32 invocationFloorCents,
33 extractUsageFromStdout,
34 LOOP_EXEC_TIMEOUT_MS,
35} from "../lib/hosted-claude-loop";
36
37const ENV_MIN = "HOSTED_LOOP_MIN_CENTS_PER_RUN";
38const ENV_RATE = "HOSTED_LOOP_MS_PER_CENT";
39
40afterEach(() => {
41 delete process.env[ENV_MIN];
42 delete process.env[ENV_RATE];
43});
44
45describe("invocationFloorCents", () => {
46 it("charges at least one cent even for an instant run", () => {
47 // This is the whole point: a snippet reporting nothing is not free.
48 expect(invocationFloorCents(0)).toBe(1);
49 expect(invocationFloorCents(1)).toBe(1);
50 });
51
52 it("scales with measured wall clock", () => {
53 expect(invocationFloorCents(10_000)).toBe(1);
54 expect(invocationFloorCents(10_001)).toBe(2);
55 expect(invocationFloorCents(25_000)).toBe(3);
56 });
57
58 it("bounds a maximum-length run sensibly", () => {
59 // Execution is hard-capped, so the floor per run is bounded too.
60 const maxFloor = invocationFloorCents(LOOP_EXEC_TIMEOUT_MS);
61 expect(maxFloor).toBeGreaterThan(0);
62 expect(maxFloor).toBeLessThanOrEqual(5);
63 });
64
65 it("never returns a negative or non-finite charge", () => {
66 for (const bad of [-1, -100_000, NaN, Infinity, -Infinity]) {
67 const got = invocationFloorCents(bad as number);
68 expect(Number.isFinite(got)).toBe(true);
69 expect(got).toBeGreaterThanOrEqual(1);
70 }
71 });
72
73 it("honours the env knobs at access time", () => {
74 process.env[ENV_MIN] = "5";
75 process.env[ENV_RATE] = "1000";
76 expect(invocationFloorCents(0)).toBe(5);
77 expect(invocationFloorCents(9_000)).toBe(9);
78 });
79
80 it("falls back to safe defaults when the env is nonsense", () => {
81 process.env[ENV_MIN] = "not-a-number";
82 process.env[ENV_RATE] = "0";
83 expect(invocationFloorCents(0)).toBe(1);
84 expect(invocationFloorCents(10_000)).toBe(1);
85 });
86
87 it("allows the floor to be disabled deliberately", () => {
88 // Explicit zero is a real operator choice, not nonsense — it must be
89 // honoured rather than silently coerced back to the default.
90 process.env[ENV_MIN] = "0";
91 expect(invocationFloorCents(0)).toBe(0);
92 });
93});
94
95describe("extractUsageFromStdout is the untrusted input", () => {
96 it("reports nothing for a snippet that prints nothing", () => {
97 // The exact shape that used to cost zero and never advance the cap.
98 expect(extractUsageFromStdout("")).toEqual({
99 inputTokens: 0,
100 outputTokens: 0,
101 model: null,
102 });
103 expect(extractUsageFromStdout("hello, no json here")).toEqual({
104 inputTokens: 0,
105 outputTokens: 0,
106 model: null,
107 });
108 });
109
110 it("still reads a well-formed usage block", () => {
111 const got = extractUsageFromStdout(
112 JSON.stringify({ usage: { input_tokens: 100, output_tokens: 50 } })
113 );
114 expect(got.inputTokens).toBe(100);
115 expect(got.outputTokens).toBe(50);
116 });
117});
118
119// --- the floor must actually be applied ------------------------------------
120//
121// Asserting the identifier merely APPEARS is not asserting it is invoked —
122// `void fn;` would satisfy that. Assert the call form. Strip ONLY line
123// comments.
124
125describe("invokeLoop applies the floor to what it charges", () => {
126 const src = (() => {
127 const raw = readFileSync(
128 join(import.meta.dir, "..", "lib", "hosted-claude-loop.ts"),
129 "utf8"
130 );
131 const stripped = raw
132 .split("\n")
133 .filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*"))
134 .join("\n");
135 expect(stripped.length).toBeGreaterThan(0);
136 return stripped;
137 })();
138
139 it("takes the max of self-reported cost and the measured floor", () => {
140 expect(src).toContain("invocationFloorCents(execResult.durationMs)");
141 expect(src).toContain("Math.max(");
142 });
143
144 it("charges the floor before the run row is written", () => {
145 // centsEstimate on the run row, the loop rollup and the agent charge all
146 // read the same `cents`, so it has to be floored before any of them.
147 const at = src.indexOf("invocationFloorCents(execResult.durationMs)");
148 expect(at).toBeGreaterThan(-1);
149 const after = src.slice(at);
150 expect(after.length).toBeGreaterThan(0);
151 expect(after).toContain("centsEstimate: cents");
152 expect(after).toContain("bumpLoopTotals(loop.id, cents)");
153 });
154
155 it("still short-circuits over-budget loops before executing", () => {
156 // The floor is worthless if the cap check itself goes away.
157 const at = src.indexOf("getLoopMonthlySpendCents(loop.id)");
158 expect(at).toBeGreaterThan(-1);
159 const window = src.slice(at, at + 400);
160 expect(window.length).toBeGreaterThan(0);
161 expect(window).toContain("loop.monthlyBudgetCents");
162 expect(window).toContain("budget_exceeded");
163 // ...and it happens before the executor is invoked.
164 expect(at).toBeLessThan(src.indexOf("await exec({"));
165 });
166});
Modifiedsrc/lib/hosted-claude-loop.ts+50−4View fileUnifiedSplit
6464/** Default model fallback when the snippet didn't tell us what it ran. */
6565const DEFAULT_MODEL = "claude-sonnet-4-6";
6666
67/**
68 * Floor charged per invocation from PLATFORM-OBSERVED wall clock.
69 *
70 * The monthly budget check is only as trustworthy as the numbers feeding it,
71 * and those came entirely from `extractUsageFromStdout` — that is, from a
72 * `usage` block printed by the snippet itself. As the comment on the env
73 * block below says outright, `loop.sourceCode` is arbitrary user-supplied
74 * code that any registered account can run. A snippet that simply never
75 * prints a usage block was costed at 0 cents, so its monthly spend never
76 * advanced and the cap could not stop it: unlimited invocations, each
77 * burning up to LOOP_EXEC_TIMEOUT_MS of platform compute, and platform
78 * Anthropic credits too wherever HOSTED_LOOPS_PLATFORM_KEY=1.
79 *
80 * Self-reported usage is still used when it is HIGHER — it is the better
81 * estimate of real token spend, and there is no incentive to overstate it.
82 * The floor only removes the option of reporting nothing. Duration is
83 * measured by the executor, so a snippet cannot forge it.
84 *
85 * Both knobs are env-tunable, read at access time, so the rate can be
86 * adjusted without a deploy. Defaults: 1 cent minimum per run, plus a cent
87 * per 10 seconds — with the smallest $5/month budget that still allows 500+
88 * invocations before the cap bites.
89 */
90function loopFloorSettings(): { minCents: number; msPerCent: number } {
91 const min = Number(process.env.HOSTED_LOOP_MIN_CENTS_PER_RUN ?? 1);
92 const per = Number(process.env.HOSTED_LOOP_MS_PER_CENT ?? 10_000);
93 return {
94 minCents: Number.isFinite(min) && min >= 0 ? Math.floor(min) : 1,
95 msPerCent: Number.isFinite(per) && per > 0 ? per : 10_000,
96 };
97}
98
99/**
100 * Minimum cents to charge for one invocation, given its measured duration.
101 * Never negative; always at least the configured per-run minimum.
102 */
103export function invocationFloorCents(durationMs: number): number {
104 const { minCents, msPerCent } = loopFloorSettings();
105 const ms =
106 Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 0;
107 return Math.max(minCents, Math.ceil(ms / msPerCent));
108}
109
67110// ---------------------------------------------------------------------------
68111// Pure helpers
69112// ---------------------------------------------------------------------------
693736
694737 const usage = extractUsageFromStdout(execResult.stdout);
695738 const model = usage.model || DEFAULT_MODEL;
696 const cents = computeCentsForCall(
697 model,
698 usage.inputTokens,
699 usage.outputTokens
739 // Self-reported when it is higher, platform-observed floor otherwise — see
740 // invocationFloorCents. Applies to failed and timed-out runs too: those
741 // consumed compute exactly like successful ones, and a snippet that always
742 // fails must not be free to invoke forever.
743 const cents = Math.max(
744 computeCentsForCall(model, usage.inputTokens, usage.outputTokens),
745 invocationFloorCents(execResult.durationMs)
700746 );
701747
702748 // Try to parse stdout as JSON for the output payload.
703749