diff --git a/backend/__tests__/unit/routes/hosted.test.js b/backend/__tests__/unit/routes/hosted.test.js index a9af45ec3a..f5cab6bf4d 100644 --- a/backend/__tests__/unit/routes/hosted.test.js +++ b/backend/__tests__/unit/routes/hosted.test.js @@ -13,6 +13,7 @@ const mockHosted = { isConfigured: jest.fn(), isHostedInstallation: jest.fn(), hostedCaps: jest.fn(() => ({ agentsPerUser: 1, turnsPerDay: 200 })), + hostedCapsForOwner: jest.fn(async () => ({ agentsPerUser: 1, turnsPerDay: 200 })), countHostedAgentsForUser: jest.fn(), listHostedInstallationsForUser: jest.fn(), meterAllowsTurn: jest.fn(), @@ -105,6 +106,14 @@ describe('/api/hosted', () => { expect(off.body.configured).toBe(false); }); + // TASK-268: the page states the caller's own plan line, not a global number. + it("reports the caller's own plan caps", async () => { + mockHosted.hostedCapsForOwner.mockResolvedValueOnce({ agentsPerUser: 1, turnsPerDay: 30 }); + const res = await request(app).get('/api/hosted/availability'); + expect(mockHosted.hostedCapsForOwner).toHaveBeenCalledWith('owner-1'); + expect(res.body.caps).toEqual({ agentsPerUser: 1, turnsPerDay: 30 }); + }); + it("records the caller's first visit to Bring your own (the mark contains its own failures)", async () => { const res = await request(app).get('/api/hosted/availability'); expect(res.status).toBe(200); diff --git a/backend/__tests__/unit/scripts/docsSiteFacts.test.js b/backend/__tests__/unit/scripts/docsSiteFacts.test.js index a72cc5d424..2d9196146a 100644 --- a/backend/__tests__/unit/scripts/docsSiteFacts.test.js +++ b/backend/__tests__/unit/scripts/docsSiteFacts.test.js @@ -51,8 +51,10 @@ describe('docs.commonly.me says what the product does', () => { test('the hosted limits match the code', () => { const turns = hostedDefault('DEFAULT_TURNS_PER_DAY'); + const proTurns = hostedDefault('DEFAULT_TURNS_PER_DAY_PRO'); const agents = hostedDefault('DEFAULT_AGENTS_PER_USER'); expect(quickstart).toContain(`${turns} turns a day`); + expect(quickstart).toContain(`${proTurns} turns a day on Pro`); expect(agents).toBe(1); expect(quickstart).toMatch(/run one hosted agent/); }); diff --git a/backend/__tests__/unit/services/hostedRuntimeService.test.js b/backend/__tests__/unit/services/hostedRuntimeService.test.js index 4c5fc9ae7a..e6c7126a8e 100644 --- a/backend/__tests__/unit/services/hostedRuntimeService.test.js +++ b/backend/__tests__/unit/services/hostedRuntimeService.test.js @@ -42,10 +42,15 @@ describe('hostedRuntimeService', () => { }); it('applies defaults and ignores garbage cap values', () => { - expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 1, turnsPerDay: 200 }); + // The plan line (TASK-268): 30 a day free, 200 on pro, and free when no plan is given. + expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 1, turnsPerDay: 30 }); + expect(hosted.hostedCaps({ pro: true })).toEqual({ agentsPerUser: 1, turnsPerDay: 200 }); process.env.HOSTED_AGENTS_PER_USER = '3'; process.env.HOSTED_TURNS_PER_DAY = 'lots'; - expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 3, turnsPerDay: 200 }); + process.env.HOSTED_TURNS_PER_DAY_PRO = 'more'; + expect(hosted.hostedCaps()).toEqual({ agentsPerUser: 3, turnsPerDay: 30 }); + expect(hosted.hostedCaps({ pro: true })).toEqual({ agentsPerUser: 3, turnsPerDay: 200 }); + delete process.env.HOSTED_TURNS_PER_DAY_PRO; }); }); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js index baee2069a0..4a9370de48 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js @@ -23,8 +23,10 @@ jest.mock('../../../models/Pod', () => ({ jest.mock('../../../models/AgentRun', () => ({ create: jest.fn(), countDocuments: jest.fn() })); +let mockOwnerPro = false; jest.mock('../../../models/User', () => ({ findOne: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve(null) }) })), + findById: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve({ entitlements: { pro: mockOwnerPro } }) }) })), })); jest.mock('../../../services/agentTypingService', () => ({ @@ -189,4 +191,34 @@ describe('nativeRuntimeService — hosted installs on the native engine', () => expect(result.status).toBe('failed'); expect(axios.post).not.toHaveBeenCalled(); }); + + // TASK-268: the plan line. Free owners get 30 hosted turns a day, pro 200, + // and a pro owner's per-user ceiling sits above the free 120. + describe('the owner\'s plan sets the line', () => { + const counts = ({ install, user }) => AgentRun.countDocuments.mockImplementation( + async (query) => (query.userId ? user : install), + ); + afterEach(() => { mockOwnerPro = false; }); + + test.each([ + ['free', false, 29, true], + ['free', false, 30, false], + ['pro', true, 30, true], + ['pro', true, 200, false], + ])('a %s owner with %i runs today: runs=%s', async (_plan, pro, installRuns, runs) => { + mockOwnerPro = pro; + counts({ install: installRuns, user: 0 }); + await runAgent(hostedInstall(), mention); + expect(axios.post).toHaveBeenCalledTimes(runs ? 1 : 0); + }); + + test('a pro owner past the free per-user ceiling (120) still runs; a free one is refused', async () => { + counts({ install: 0, user: 150 }); + mockOwnerPro = true; + expect((await runAgent(hostedInstall(), mention)).status).toBe('succeeded'); + mockOwnerPro = false; + const refused = await runAgent(hostedInstall(), mention); + expect(refused).toEqual(expect.objectContaining({ status: 'failed', errorKind: 'user_ceiling' })); + }); + }); }); diff --git a/backend/models/User.ts b/backend/models/User.ts index 56d3765d5b..e5e1f4a41c 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -99,9 +99,10 @@ export interface IUser extends Document { // BYO agents are NEVER gated by this: "agents you bring connect free and // unlimited" is the product's standing promise, and a per-agent cap is the // thing this pricing model exists to avoid. - // NEVER set from a client response — `billingService` writes it from a - // signature-verified Stripe webhook, the only source of truth about - // whether money actually moved. + // NEVER set from a client response. Two authorized writers: `billingService` + // from a signature-verified Stripe webhook (the source of truth about + // whether money moved), and an instance admin's entitlements PATCH + // (routes/admin/users.ts) for pilots and design partners. pro: boolean; }; // Stripe linkage. `customerId` is the join key from webhook -> user; it is diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 9c731a1aae..f1bcf1bf44 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -611,7 +611,8 @@ router.get('/events', longPollIpLimit, agentRuntimeAuth, async (req: any, res: a // daily turn budget the feed goes empty for it — the worker idles instead // of spending. Events stay pending and deliver after the UTC reset. if (installation && HostedRuntimeMeter.isHostedInstallation(installation)) { - const meter = await HostedRuntimeMeter.meterAllowsTurn(agentName, instanceId); + const { turnsPerDay } = await HostedRuntimeMeter.hostedCapsForOwner(installation.installedBy); + const meter = await HostedRuntimeMeter.meterAllowsTurn(agentName, instanceId, turnsPerDay); if (!meter.allowed) { return res.json({ events: [], meter }); } diff --git a/backend/routes/billing.ts b/backend/routes/billing.ts index 2fb253054e..da5d9e8b6b 100644 --- a/backend/routes/billing.ts +++ b/backend/routes/billing.ts @@ -154,7 +154,10 @@ router.post('/portal', checkoutLimit, auth, async (req: any, res: any) => { }); /** - * POST /api/billing/webhook — the ONLY writer of entitlements.pro. + * POST /api/billing/webhook — the writer of entitlements.pro for paying + * customers. The other authorized writer is an instance admin's PATCH + * /api/admin/users/:userId/entitlements (pilots, design partners); no client + * request sets it. * * NO `auth` middleware by design: Stripe is the caller, and the signature is * the authentication. Mounted with `express.raw` in server.ts before the diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index 81bdb413b6..c6c2bc132b 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -179,7 +179,9 @@ const relayWorkerError = (res: express.Response, error: any) => { router.get('/availability', hostedRateLimit, auth, async (req: any, res: any) => { markByoOpened(getUserId(req)); const engine = engineForNewHostedInstall(); - return res.json({ configured: engineAvailable(engine), engine, caps: hostedRuntime.hostedCaps() }); + // The caller's own plan line (TASK-268), so the page states the limit they get. + const caps = await hostedRuntime.hostedCapsForOwner(getUserId(req)); + return res.json({ configured: engineAvailable(engine), engine, caps }); }); /** @@ -242,7 +244,7 @@ router.post('/provision', hostedRateLimit, auth, async (req: any, res: any) => { agentName, instanceId, podId: installation.podId, - caps: hostedRuntime.hostedCaps(), + caps: await hostedRuntime.hostedCapsForOwner(userId), }); } @@ -265,7 +267,7 @@ router.post('/provision', hostedRateLimit, auth, async (req: any, res: any) => { agentName, instanceId, podId: installation.podId, - caps: hostedRuntime.hostedCaps(), + caps: await hostedRuntime.hostedCapsForOwner(userId), }); } catch (error) { return relayWorkerError(res, error); @@ -321,7 +323,8 @@ router.get('/status', hostedRateLimit, auth, async (req: any, res: any) => { // No loop to report on: a native-engine agent is ready once provisioned, // and the runtime it needs is available (checked just above). const { provisionedAt } = readHostedConfig(installation); - const meter = await hostedRuntime.meterAllowsTurn(agentName, instanceId); + const { turnsPerDay } = await hostedRuntime.hostedCapsForOwner(getUserId(req)); + const meter = await hostedRuntime.meterAllowsTurn(agentName, instanceId, turnsPerDay); return res.json({ agentName, instanceId, @@ -331,7 +334,8 @@ router.get('/status', hostedRateLimit, auth, async (req: any, res: any) => { } const [runtime, meter] = await Promise.all([ hostedRuntime.getAgentStatus(agentName, instanceId), - hostedRuntime.meterAllowsTurn(agentName, instanceId), + hostedRuntime.hostedCapsForOwner(getUserId(req)) + .then(({ turnsPerDay }: { turnsPerDay: number }) => hostedRuntime.meterAllowsTurn(agentName, instanceId, turnsPerDay)), ]); return res.json({ agentName, instanceId, runtime, meter }); } catch (error) { diff --git a/backend/services/hostedEngine.ts b/backend/services/hostedEngine.ts index 428aeed4e9..2683d7ee37 100644 --- a/backend/services/hostedEngine.ts +++ b/backend/services/hostedEngine.ts @@ -17,7 +17,7 @@ * * Every site that has to tell the two engines apart asks this module. */ -import { hostedCaps, isHostedInstallation, readRuntimeConfig } from './hostedRuntimeService'; +import { isHostedInstallation, readRuntimeConfig } from './hostedRuntimeService'; export const HOSTED_ENGINE_NATIVE = 'native'; export const HOSTED_ENGINE_DURABLE_OBJECT = 'durable-object'; @@ -93,9 +93,9 @@ export const HOSTED_NATIVE_EVENT_TYPES: ReadonlySet = new Set(['chat.men * Object's meter, while the native runtime reads a `dailyRunCap` of 0 as no * cap, so the zero is honoured here rather than passed through. */ -export const hostedNativeDecline = (eventType: string): string | null => { +export const hostedNativeDecline = (eventType: string, turnsPerDay: number): string | null => { if (!HOSTED_NATIVE_EVENT_TYPES.has(eventType)) return `event type ${eventType || 'unset'} is not answered by hosted agents`; - if (hostedCaps().turnsPerDay <= 0) return 'hosted turns are switched off (HOSTED_TURNS_PER_DAY=0)'; + if (turnsPerDay <= 0) return 'hosted turns are switched off for this plan (HOSTED_TURNS_PER_DAY*=0)'; return null; }; @@ -137,7 +137,7 @@ export const hostedNativeSystemPrompt = (displayName: string, about?: string): s * installs can carry those fields because only entitled operators make them. * The model is left unset so the runtime's default applies. */ -export const hostedNativeRunConfig = (installation: any): Record => { +export const hostedNativeRunConfig = (installation: any, turnsPerDay: number): Record => { const hosted = readHostedConfig(installation); const displayName = String(installation?.displayName || installation?.agentName || 'Commonly Agent'); const about = typeof hosted.about === 'string' ? hosted.about : undefined; @@ -147,9 +147,9 @@ export const hostedNativeRunConfig = (installation: any): Record { @@ -43,11 +45,39 @@ const intEnv = (name: string, fallback: number): number => { return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; }; -export const hostedCaps = () => ({ +/** + * The hosted caps for a plan: 30 turns a day free, 200 on pro. Pro is + * `entitlements.pro`, which two authorized paths write: the signed Stripe + * webhook (routes/billing.ts) and an instance admin's entitlements PATCH + * (routes/admin/users.ts, how pilots and design partners get Pro). No client + * field sets it. Called without a plan it returns the free caps: a cap never + * defaults to the paid line. + */ +export const hostedCaps = ({ pro = false }: { pro?: boolean } = {}) => ({ agentsPerUser: intEnv('HOSTED_AGENTS_PER_USER', DEFAULT_AGENTS_PER_USER), - turnsPerDay: intEnv('HOSTED_TURNS_PER_DAY', DEFAULT_TURNS_PER_DAY), + turnsPerDay: pro + ? intEnv('HOSTED_TURNS_PER_DAY_PRO', DEFAULT_TURNS_PER_DAY_PRO) + : intEnv('HOSTED_TURNS_PER_DAY', DEFAULT_TURNS_PER_DAY), }); +/** Whether a user is on pro. A missing user or a failed lookup reads as free. */ +export const ownerIsPro = async (userId: unknown): Promise => { + const id = String(userId ?? ''); + if (!/^[a-f\d]{24}$/i.test(id)) return false; + try { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const User = require('../models/User'); + const user = await User.findById(id).select('entitlements.pro').lean(); + return user?.entitlements?.pro === true; + } catch (error) { + console.warn('[hosted] plan lookup failed; using the free caps:', (error as Error).message); + return false; + } +}; + +/** The hosted caps for whoever owns (installed) a hosted agent. */ +export const hostedCapsForOwner = async (userId: unknown) => hostedCaps({ pro: await ownerIsPro(userId) }); + export const hostedRuntimeConfig = () => ({ url: String(process.env.HOSTED_RUNTIME_URL || '').trim().replace(/\/+$/, ''), adminToken: String(process.env.HOSTED_RUNTIME_ADMIN_TOKEN || '').trim(), @@ -129,8 +159,11 @@ export interface MeterResult { resetsAt: string; } -export const meterAllowsTurn = async (agentName: string, instanceId: string): Promise => { - const { turnsPerDay } = hostedCaps(); +export const meterAllowsTurn = async ( + agentName: string, + instanceId: string, + turnsPerDay: number = hostedCaps().turnsPerDay, +): Promise => { const used = await turnsToday(agentName, instanceId); const tomorrow = new Date(utcDayStart().getTime() + 24 * 60 * 60 * 1000); return { @@ -223,6 +256,8 @@ export const getAgentStatus = async (agentName: string, instanceId: string): Pro module.exports = { HOSTED_RUNTIME_TYPE, hostedCaps, + ownerIsPro, + hostedCapsForOwner, hostedRuntimeConfig, isConfigured, readRuntimeConfig, diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index fdb7f5003c..e3acc4dfb4 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -35,6 +35,7 @@ import { podHasOutsiderWritableGrant } from './nativeOutsiderGrantPolicy'; import { isSandboxConfigured, TurnSandbox } from './turnSandboxService'; import { untrustedConnectorMarker } from './brokerResultMarker'; import { hostedNativeDecline, hostedNativeRunConfig, hostedRunsNative } from './hostedEngine'; +import { hostedCapsForOwner, ownerIsPro } from './hostedRuntimeService'; // --- public surface -------------------------------------------------------- @@ -807,6 +808,10 @@ export async function runAgent( // too, on a configuration built server-side: its stored config came from the // browser and must not choose the model, the tools or the cap. const hostedNative = hostedRunsNative(installation); + // The owner's plan line (TASK-268): 30 hosted turns a day free, 200 on pro. + const hostedTurnsPerDay = hostedNative + ? (await hostedCapsForOwner(installation.installedBy)).turnsPerDay + : 0; if (runtimeType !== 'native' && !hostedNative) { return failedResult( '', @@ -815,7 +820,7 @@ export async function runAgent( ); } if (hostedNative) { - const decline = hostedNativeDecline(String(trigger?.type || '')); + const decline = hostedNativeDecline(String(trigger?.type || ''), hostedTurnsPerDay); if (decline) { // Acked by the caller, with no AgentRun: nothing ran. return { @@ -823,7 +828,7 @@ export async function runAgent( }; } } - const cfg = hostedNative ? normalizeConfig(hostedNativeRunConfig(installation)) : installedCfg; + const cfg = hostedNative ? normalizeConfig(hostedNativeRunConfig(installation, hostedTurnsPerDay)) : installedCfg; const podId = String(installation.podId); const agentName = String(installation.agentName || '').toLowerCase(); @@ -873,8 +878,8 @@ export async function runAgent( // a runaway guard and a spend ceiling have opposite safe directions. // Forward-only: rows without userId predate the stamp and cannot be // counted; the window inherits the cap's fixed-UTC-midnight semantics. - const userCeiling = Number(process.env.AGENT_USER_DAILY_RUN_CEILING ?? 120); - if (Number.isFinite(userCeiling) && userCeiling > 0) { + const freeCeiling = Number(process.env.AGENT_USER_DAILY_RUN_CEILING ?? 120); + if (Number.isFinite(freeCeiling) && freeCeiling > 0) { const installedBy = installation.installedBy ? String(installation.installedBy) : ''; if (!installedBy) { console.warn( @@ -882,6 +887,13 @@ export async function runAgent( + 'per-user ceiling unenforceable for this install', ); } else { + // A pro owner's hosted line (200 turns a day, TASK-268) sits above the + // free ceiling, so pro owners get the pro ceiling; a failed plan lookup + // reads as free, the lower one. + const proCeiling = Number(process.env.AGENT_USER_DAILY_RUN_CEILING_PRO ?? 300); + const userCeiling = (await ownerIsPro(installedBy)) && Number.isFinite(proCeiling) && proCeiling > 0 + ? proCeiling + : freeCeiling; try { const AgentRunModel = require('../models/AgentRun'); const dayStart = new Date(); diff --git a/docs-site/quickstart.mdx b/docs-site/quickstart.mdx index ce2b46a3bf..808671ea61 100644 --- a/docs-site/quickstart.mdx +++ b/docs-site/quickstart.mdx @@ -31,7 +31,7 @@ run an agent: pod in about ten seconds. During the beta, every signed-in account can run one hosted agent, with - 200 turns a day. + 30 turns a day, or 200 turns a day on Pro. Choose **Bring your own runtime**, name the agent, pick the pod, and click