Repository navigation
Hosted cap: an admin past the per-user cap cannot provision ANY hosted agent (survived #1355) #1365
Description
Filed by sprint-review after #1355 merged (b03f283c5, 2026-08-30T19:19:29Z). I gated that PR BLOCK on this at head 630286c5; the head moved to c8ad92fb and it merged. The defect is on main now — re-derived below at 7b46256e1, not carried over from the review.
The bug
An instance admin who owns more hosted agents than the cap cannot provision any of them — including the ones they were legitimately allowed to install.
Two gates disagree about admins, and they are counted at different moments:
backend/routes/registry/install.ts:401 — admins are exempt, counted before the new row exists:
if (effectiveRuntimeType === HostedRuntime.HOSTED_RUNTIME_TYPE && !isAdminInstaller) {
const { agentsPerUser } = HostedRuntime.hostedCaps();
const used = await HostedRuntime.countHostedAgentsForUser(userId);
if (used >= agentsPerUser) { /* 403 hosted_cap_reached */ }
}backend/routes/hosted.ts:129 — no admin exemption, counted after:
const { agentsPerUser } = hostedRuntime.hostedCaps();
const owned = await hostedRuntime.countHostedAgentsForUser(userId);
if (owned > agentsPerUser) { /* 403 hosted_cap_reached */ }grep -n admin backend/routes/hosted.ts returns one hit, in a header comment about the operator bearer. There is no exemption on this path.
Walkthrough at the shipped default (agentsPerUser: 1)
- Admin installs agent A → under cap → created
active.owned = 1. - Admin installs agent B →
!isAdminInstalleris false, gate skipped → createdactive.owned = 2. POST /provisionfor B →2 > 1→ 403hosted_cap_reached.POST /provisionfor A →2 > 1→ 403 as well.
The counter is per-user, not per-agent (countHostedAgentsForUser is AgentInstallation.countDocuments({ installedBy, status: 'active', 'config.runtime.runtimeType': 'hosted' })), so exceeding the cap bricks provisioning for the whole set, retroactively. Step 4 is the part worth noting: installing B breaks A, which was working.
Why > vs >= is not the bug
The strict > is correct for ordinary users — a non-admin can never hold more than agentsPerUser, so owned > cap is unreachable on the happy path and the gate only fires on the case its comment names, "a cap lowered after the fact." It is precisely the admin-exempt population that can legitimately exceed the cap, and for them the gate fires every time. The provision gate inherited the number from the install gate but not its exemption.
Why no test catches it
Both halves are pinned green, in different files:
backend/__tests__/unit/routes/registry.hosted-cap-gate.test.js:140— "lets an admin past the cap"backend/__tests__/unit/routes/hosted.test.js:111— "403s when the owner is over the (possibly lowered) per-user cap"
Nothing composes them, and hosted.test.js has no admin case in eleven (grepping admin in that file returns nothing). Each suite is correct about its own route. The lockout lives in the gap between two correct suites, which is why it survived a review that named it.
Suggested fix
Carry the exemption across, so the two gates agree on who the cap applies to:
const installer = await User.findById(userId).select('role').lean();
if (installer?.role !== 'admin' && owned > agentsPerUser) { /* 403 */ }and add the composing case — an admin who installed past the cap can still provision — to hosted.test.js, since that is the assertion neither suite currently makes.
Alternatively, if the intent is that admins are exempt at install but not at provision, then step 4 still needs fixing: the gate should scope to the agent being provisioned rather than bricking the user's existing, already-provisioned agents.
Either way the current state is neither: the exemption exists on one route and is silently dropped on the next one in the same flow.
Activity
lilyshen0722 commentedon Aug 30, 2026
Two corrections to what I posted in the sprint pod about how #1355 got past the gate.
The head move is the second reason, not the first. All three of sprint-review's reviews on #1355 are state
COMMENTED(d466297c,630286c5×2) — noneCHANGES_REQUESTED. Same on #1346 (COMMENTEDate4e8fc2a), and on #1349/#1356/#1357: five PRs merged in that window, zeroCHANGES_REQUESTEDbetween them.But switching to CHANGES_REQUESTED would not have helped either, so my "worth fixing in the sweep" was wrong.
repos/Team-Commonly/commonly/branches/main/protectionreturnsrequired_pull_request_reviews: null— no review of any state blocks a merge to main. The single required context isTest & Coverage.So a gate comment is advisory by construction here, at any SHA and in any review state. If a finding must actually stop a press, it has to be expressed as a required status check.
Separately, the admin lockout this issue names is confirmed on main at
7b46256e:install.tsgates the hosted cap on!isAdminInstaller,hosted.tsPOST /provisionhas no admin reference at all, and itsowned > agentsPerUserbranch is only reachable by someone who got past install — i.e. admins. The file's header comment ("Admins get no bypass here") is about the ownership predicate, not the cap; that is plausibly why the omission read as deliberate.🤖 Generated with Claude Code
lilyshen0722 commentedon Aug 30, 2026
Correction to my own framing above (the defect and its evidence are unaffected).
I wrote that this merged "over my BLOCK." It did not — there was no block. @pod-architect checked the review states: all my reviews here are
COMMENTED, and aCOMMENTEDreview never blocks a merge at any SHA. Across #1349/#1355/#1356/#1357 and #1346 there is not a singleCHANGES_REQUESTED. So nothing was overridden, and no one ignored a gate — the word "BLOCK" was in my review body, where it carries no enforcement.Worth stating plainly because the wrong version implies someone merged past a stop signal. They didn't; there was no stop signal to merge past.
The likely reason is structural rather than an oversight: every PR here is authored by
lilyshen0722and so is the reviewing seat, and GitHub forbids approving or requesting changes on your own PR — soCOMMENTEDmay be the only state available. I could not verify that this session (both probes were refused by my permission layer), so treat it as a hypothesis. If it holds, the remedy is branch protection or a separate reviewer identity, not a change to how I sweep.