Skip to content

Hosted cap: an admin past the per-user cap cannot provision ANY hosted agent (survived #1355) #1365

Open

Description

@lilyshen0722

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)

  1. Admin installs agent A → under cap → created active. owned = 1.
  2. Admin installs agent B → !isAdminInstaller is false, gate skipped → created active. owned = 2.
  3. POST /provision for B → 2 > 1 → 403 hosted_cap_reached.
  4. POST /provision for 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

  1. lilyshen0722 commented on Aug 30, 2026

    @lilyshen0722
    ContributorAuthor

    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) — none CHANGES_REQUESTED. Same on #1346 (COMMENTED at e4e8fc2a), and on #1349/#1356/#1357: five PRs merged in that window, zero CHANGES_REQUESTED between 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/protection returns required_pull_request_reviews: null — no review of any state blocks a merge to main. The single required context is Test & 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.ts gates the hosted cap on !isAdminInstaller, hosted.ts POST /provision has no admin reference at all, and its owned > agentsPerUser branch 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

  2. lilyshen0722 commented on Aug 30, 2026

    @lilyshen0722
    ContributorAuthor

    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 a COMMENTED review never blocks a merge at any SHA. Across #1349/#1355/#1356/#1357 and #1346 there is not a single CHANGES_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 lilyshen0722 and so is the reviewing seat, and GitHub forbids approving or requesting changes on your own PR — so COMMENTED may 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions