From 87da8676d6c5b46c4829eef182353b9c7b4a0627 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 22 Sep 2026 13:10:26 +0200 Subject: [PATCH 1/2] feat(cloud-agent-next): add containers billing identity from instance size --- .../src/container-capacity-parity.test.ts | 57 +++++++++++-- .../src/container-usage-context.test.ts | 63 +++++++++++++- .../src/container-usage-context.ts | 84 +++++++++++++++++-- .../cloud-agent-next/src/container-usage.ts | 7 +- .../src/metered-billing-lifecycle.ts | 11 +-- 5 files changed, 196 insertions(+), 26 deletions(-) diff --git a/services/cloud-agent-next/src/container-capacity-parity.test.ts b/services/cloud-agent-next/src/container-capacity-parity.test.ts index 9cc195b5fb..faed3dcd73 100644 --- a/services/cloud-agent-next/src/container-capacity-parity.test.ts +++ b/services/cloud-agent-next/src/container-capacity-parity.test.ts @@ -1,16 +1,27 @@ import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { parse } from 'jsonc-parser'; import { describe, expect, it } from 'vitest'; -import { containerCapacityForService } from '../../../apps/web/src/lib/cloudflare/container-capacity.js'; -import { SANDBOX_CAPACITIES, type SandboxClassName } from './container-usage-context.js'; +import { CLOUDFLARE_CONTAINERS_INSTANCES } from '@kilocode/worker-utils/sandbox-allocation'; + +import { + containerCapacityForService, + formatContainerCapacity, +} from '../../../apps/web/src/lib/cloudflare/container-capacity.js'; +import { + CONTAINERS_BILLING_CAPACITIES, + containersBillingIdentity, + SANDBOX_CAPACITIES, + type LegacySandboxClassName, +} from './container-usage-context.js'; type UnmeteredSandboxClassName = 'SandboxContainers'; type WranglerContainer = { - class_name: SandboxClassName | UnmeteredSandboxClassName; + class_name: LegacySandboxClassName | UnmeteredSandboxClassName; instance_type?: { vcpu: number; memory_mib: number; @@ -19,7 +30,7 @@ type WranglerContainer = { }; type MeteredWranglerContainer = { - class_name: SandboxClassName; + class_name: LegacySandboxClassName; instance_type: { vcpu: number; memory_mib: number; @@ -31,7 +42,7 @@ type WranglerConfig = { containers: WranglerContainer[]; }; -const SERVICE_BY_CLASS: Record = { +const SERVICE_BY_CLASS: Record = { Sandbox: 'cloud-agent-next-sandbox', SandboxContainment: 'cloud-agent-next-sandbox-containment', SandboxSmall: 'cloud-agent-next-sandbox-small', @@ -53,7 +64,9 @@ describe('production container capacity parity', () => { const unmetered = config.containers.filter(container => !isMeteredContainer(container)); expect(unmetered).toHaveLength(1); + expect(unmetered[0]?.class_name).toBe('SandboxContainers'); expect(unmetered[0]?.instance_type).toBeUndefined(); + expect('SandboxContainers' in SANDBOX_CAPACITIES).toBe(false); const metered = config.containers.filter(isMeteredContainer); const classNames = metered.map(container => container.class_name); @@ -74,4 +87,38 @@ describe('production container capacity parity', () => { }); } }); + + it('matches containers billing capacities to the selectable instances and web labels', () => { + const resolved = CLOUDFLARE_CONTAINERS_INSTANCES.map(instance => ({ + instance, + identity: containersBillingIdentity(instance), + })); + expect(new Set(resolved.map(entry => entry.identity.className))).toEqual( + new Set(Object.keys(CONTAINERS_BILLING_CAPACITIES)) + ); + + const webSource = fs.readFileSync( + fileURLToPath( + new URL( + '../../../apps/web/src/components/cloud-agent-next/sandbox-selection.ts', + import.meta.url + ).href + ), + 'utf8' + ); + for (const { instance, identity } of resolved) { + const { capacity } = identity; + expect(webSource).toContain( + `'${instance}': '${formatContainerCapacity({ + vcpu: capacity.vcpu, + memoryBytes: capacity.memoryMiB * 1024 ** 2, + diskBytes: capacity.diskMB * 1_000_000, + })}',` + ); + } + + // Web labels show vCPU and memory only; disk follows the Cloudflare instance-type table. + expect(CONTAINERS_BILLING_CAPACITIES.SandboxContainersStandard3.diskMB).toBe(16_000); + expect(CONTAINERS_BILLING_CAPACITIES.SandboxContainersStandard4.diskMB).toBe(20_000); + }); }); diff --git a/services/cloud-agent-next/src/container-usage-context.test.ts b/services/cloud-agent-next/src/container-usage-context.test.ts index 1c8fd59058..d29233e8c3 100644 --- a/services/cloud-agent-next/src/container-usage-context.test.ts +++ b/services/cloud-agent-next/src/container-usage-context.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import type { SandboxInstance } from './types.js'; +import type { SandboxId, SandboxInstance } from './types.js'; import type { SessionMetadata } from './persistence/session-metadata.js'; import { assertSandboxBillingAllocation, buildSandboxBillingInput, configureSandboxBillingInput, + containersBillingIdentity, forceDestroyControlPlaneSandbox, getSandboxBillingRuntimeStatus, SANDBOX_CAPACITIES, @@ -109,10 +110,68 @@ describe('container usage context', () => { SandboxDIND: 'cloud-agent-dind-2026-07', SandboxCodeReview: 'cloud-agent-code-review-2026-07', SandboxCodeReviewContainment: 'cloud-agent-code-review-2026-07', + SandboxContainersStandard3: 'cloud-agent-containers-standard-3-2026-09', + SandboxContainersStandard4: 'cloud-agent-containers-standard-4-2026-09', }); }); - it('snapshots the configured capacity for every sandbox class', () => { + it('resolves a containers identity per instance size', () => { + expect(containersBillingIdentity('standard-3')).toEqual({ + className: 'SandboxContainersStandard3', + service: 'cloud-agent-next-sandbox-containers-standard3', + sku: 'cloud-agent-containers-standard-3-2026-09', + capacity: { vcpu: 2, memoryMiB: 8_192, diskMB: 16_000 }, + }); + expect(containersBillingIdentity('standard-4')).toEqual({ + className: 'SandboxContainersStandard4', + service: 'cloud-agent-next-sandbox-containers-standard4', + sku: 'cloud-agent-containers-standard-4-2026-09', + capacity: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 }, + }); + + expect(() => containersBillingIdentity('lite')).toThrow( + 'Containers billing is unsupported for instance size: lite' + ); + expect(() => containersBillingIdentity('standard-1')).toThrow( + 'Containers billing is unsupported for instance size: standard-1' + ); + expect(() => containersBillingIdentity('standard-2')).toThrow( + 'Containers billing is unsupported for instance size: standard-2' + ); + + for (const instance of ['constructor', 'toString', '__proto__', 'standard-5']) { + expect(() => containersBillingIdentity(instance)).toThrow( + `Containers billing is unsupported for instance size: ${instance}` + ); + } + }); + + it('accepts a containers class against an isolated `ses` billing ID and rejects a bare ID', () => { + const containersClasses = ['SandboxContainersStandard3', 'SandboxContainersStandard4'] as const; + const attribution = { + subject: { type: 'user', id: 'user_containers' }, + actor: { type: 'user', id: 'user_containers' }, + sessionId: 'agent_1', + metadata: { origin: 'cloud-agent' }, + } as const; + + for (const sandboxClassName of containersClasses) { + expect(() => + assertSandboxBillingAllocation(sandboxClassName, { + sandboxId: 'ses-abcdef', + ...attribution, + }) + ).not.toThrow(); + + for (const sandboxId of ['abcdef' as SandboxId, 'org-abcdef' as SandboxId]) { + expect(() => + assertSandboxBillingAllocation(sandboxClassName, { sandboxId, ...attribution }) + ).toThrow(`${sandboxClassName} billing received an incompatible sandbox ID`); + } + } + }); + + it('snapshots the configured capacity for every legacy sandbox class', () => { expect(SANDBOX_CAPACITIES).toEqual({ Sandbox: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 }, SandboxContainment: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 }, diff --git a/services/cloud-agent-next/src/container-usage-context.ts b/services/cloud-agent-next/src/container-usage-context.ts index 2486a88230..17f03134f9 100644 --- a/services/cloud-agent-next/src/container-usage-context.ts +++ b/services/cloud-agent-next/src/container-usage-context.ts @@ -6,6 +6,7 @@ import { type UsageContext, } from '@kilocode/container-usage'; import { z } from 'zod'; +import type { CloudflareContainersInstance } from '@kilocode/worker-utils/sandbox-allocation'; import { logger } from './logger.js'; import { classifySandboxId, isIsolatedSandboxId, isValidSandboxId } from './sandbox-id.js'; import type { SessionMetadata } from './persistence/session-metadata.js'; @@ -20,17 +21,24 @@ export const SANDBOX_USAGE_SKUS = { SandboxDIND: 'cloud-agent-dind-2026-07', SandboxCodeReview: 'cloud-agent-code-review-2026-07', SandboxCodeReviewContainment: 'cloud-agent-code-review-2026-07', + SandboxContainersStandard3: 'cloud-agent-containers-standard-3-2026-09', + SandboxContainersStandard4: 'cloud-agent-containers-standard-4-2026-09', } as const; export type SandboxClassName = keyof typeof SANDBOX_USAGE_SKUS; +export type SandboxCapacity = { vcpu: number; memoryMiB: number; diskMB: number }; + +export type ContainersBillingClassName = + | 'SandboxContainersStandard3' + | 'SandboxContainersStandard4'; + +export type LegacySandboxClassName = Exclude; + // Production values mirror this service's top-level wrangler.jsonc entries and // apps/web/src/lib/cloudflare/container-capacity.ts. The parity test reads all three sources. // Development intentionally uses different named instance types and does not query Analytics. -export const SANDBOX_CAPACITIES: Record< - SandboxClassName, - { vcpu: number; memoryMiB: number; diskMB: number } -> = { +export const SANDBOX_CAPACITIES: Record = { Sandbox: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 }, SandboxContainment: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 }, SandboxSmall: { vcpu: 2, memoryMiB: 6_144, diskMB: 10_000 }, @@ -39,6 +47,62 @@ export const SANDBOX_CAPACITIES: Record< SandboxCodeReview: { vcpu: 1, memoryMiB: 4_096, diskMB: 8_000 }, SandboxCodeReviewContainment: { vcpu: 1, memoryMiB: 4_096, diskMB: 8_000 }, }; + +// One `SandboxContainers` Durable Object serves every instance size, so there is no per-size +// wrangler class; these billing classes carry the instance-keyed metering capacity. +export const CONTAINERS_BILLING_CAPACITIES: Record = { + SandboxContainersStandard3: { vcpu: 2, memoryMiB: 8_192, diskMB: 16_000 }, + SandboxContainersStandard4: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 }, +}; + +const USAGE_SERVICE_ROOT = 'cloud-agent-next'; + +export function usageServiceForSandboxClass(sandboxClassName: SandboxClassName): string { + const suffix = sandboxClassName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + return `${USAGE_SERVICE_ROOT}-${suffix}`; +} + +export function isContainersBillingClassName( + sandboxClassName: SandboxClassName +): sandboxClassName is ContainersBillingClassName { + return sandboxClassName in CONTAINERS_BILLING_CAPACITIES; +} + +export function billingCapacityForSandboxClass( + sandboxClassName: SandboxClassName +): SandboxCapacity { + return isContainersBillingClassName(sandboxClassName) + ? CONTAINERS_BILLING_CAPACITIES[sandboxClassName] + : SANDBOX_CAPACITIES[sandboxClassName]; +} + +export type ContainersBillingIdentity = { + className: ContainersBillingClassName; + service: string; + sku: string; + capacity: SandboxCapacity; +}; + +const CONTAINERS_CLASS_BY_INSTANCE: Record = { + 'standard-3': 'SandboxContainersStandard3', + 'standard-4': 'SandboxContainersStandard4', +} satisfies Record; + +export function containersBillingIdentity(instance: string): ContainersBillingIdentity { + const className = Object.hasOwn(CONTAINERS_CLASS_BY_INSTANCE, instance) + ? CONTAINERS_CLASS_BY_INSTANCE[instance] + : undefined; + if (className === undefined) { + throw new Error(`Containers billing is unsupported for instance size: ${instance}`); + } + return { + className, + service: usageServiceForSandboxClass(className), + sku: SANDBOX_USAGE_SKUS[className], + capacity: CONTAINERS_BILLING_CAPACITIES[className], + }; +} + export type SandboxBillingInput = Omit & { sandboxId: SandboxId; enforcementRequested?: boolean; @@ -145,7 +209,7 @@ export function parseSandboxBillingInput(input: unknown): SandboxBillingInput { const parsed = sandboxBillingInputEnvelopeSchema.parse(input); const { sandboxId, enforcementRequested, ...usageInput } = parsed; const validated = usageContextSchema.parse({ - service: 'cloud-agent-next', + service: USAGE_SERVICE_ROOT, instanceId: 'validation', sku: 'validation', ...usageInput, @@ -179,10 +243,12 @@ export function assertSandboxBillingAllocation( const expectedSandboxIdClass = standardClass ? 'isolated-standard' - : sandboxClassName === 'SandboxDIND' - ? 'devcontainer' - : sandboxClassName === 'SandboxSmall' || sandboxClassName === 'SandboxSmallContainment' - ? 'isolated-small' + : isContainersBillingClassName(sandboxClassName) || + sandboxClassName === 'SandboxSmall' || + sandboxClassName === 'SandboxSmallContainment' + ? 'isolated-small' + : sandboxClassName === 'SandboxDIND' + ? 'devcontainer' : 'code-review'; if (sandboxIdClass !== expectedSandboxIdClass) { throw new Error(`${sandboxClassName} billing received an incompatible sandbox ID`); diff --git a/services/cloud-agent-next/src/container-usage.ts b/services/cloud-agent-next/src/container-usage.ts index 169a2a7264..e34580cda7 100644 --- a/services/cloud-agent-next/src/container-usage.ts +++ b/services/cloud-agent-next/src/container-usage.ts @@ -7,10 +7,13 @@ import { } from '@kilocode/container-usage'; import { Sandbox as StockSandbox } from '@cloudflare/sandbox'; import type { Env } from './types.js'; -import type { SandboxBillingAdmissionResult, SandboxClassName } from './container-usage-context.js'; import { - MeteredBillingLifecycle, usageServiceForSandboxClass, + type SandboxBillingAdmissionResult, + type SandboxClassName, +} from './container-usage-context.js'; +import { + MeteredBillingLifecycle, type BillingIdentity, type ContainerStopParams, } from './metered-billing-lifecycle.js'; diff --git a/services/cloud-agent-next/src/metered-billing-lifecycle.ts b/services/cloud-agent-next/src/metered-billing-lifecycle.ts index 31ae9bc15d..9723e28afb 100644 --- a/services/cloud-agent-next/src/metered-billing-lifecycle.ts +++ b/services/cloud-agent-next/src/metered-billing-lifecycle.ts @@ -16,16 +16,16 @@ import { z } from 'zod'; import { logger } from './logger.js'; import { assertSandboxBillingAllocation, + billingCapacityForSandboxClass, parseSandboxBillingInput, - SANDBOX_CAPACITIES, SANDBOX_USAGE_SKUS, + usageServiceForSandboxClass, billingAdmissionFailureFromError, type SandboxBillingAdmissionResult, type SandboxBillingInput, type SandboxClassName, } from './container-usage-context.js'; -const SERVICE = 'cloud-agent-next'; const PENDING_ATTRIBUTION_STORAGE_KEY = 'container-usage:pending-attribution:v1'; const PENDING_STOP_REASON_STORAGE_KEY = 'container-usage:pending-stop-reason:v1'; const START_ACK_GENERATION_STORAGE_KEY = 'container-usage:start-ack-generation:v1'; @@ -81,11 +81,6 @@ function startInputFromContext(context: BillingContext): ClientRecordStartInput return { ...usage, startEpochMs: context.startEpochMs }; } -export function usageServiceForSandboxClass(sandboxClassName: SandboxClassName): string { - const suffix = sandboxClassName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); - return `${SERVICE}-${suffix}`; -} - function stoppedAtFromState( state: { status: string; lastChange?: number }, observedAtMs = Date.now() @@ -693,7 +688,7 @@ export class MeteredBillingLifecycle { input: SandboxBillingInput, trigger: ContainerStartTrigger ): Promise { - const capacity = SANDBOX_CAPACITIES[identity.sandboxClassName]; + const capacity = billingCapacityForSandboxClass(identity.sandboxClassName); const previousStartEpochMs = (await this.host.storage.get(LAST_START_EPOCH_STORAGE_KEY)) ?? -1; const startEpochMs = Math.max(Date.now(), previousStartEpochMs + 1); From 9fc549a3bc3be39d2907d9413e672a446ae01fce Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 22 Sep 2026 22:33:53 +0200 Subject: [PATCH 2/2] fix(cloud-agent-next): reject inherited object keys in containers class check isContainersBillingClassName used the prototype-inclusive `in` operator while containersBillingIdentity in the same file uses Object.hasOwn, so a name like `toString` or `constructor` classified as a containers class and billingCapacityForSandboxClass could resolve Object.prototype as a capacity. Use Object.hasOwn so the two checks share one own-key rule and cannot drift. Addresses PR #6573 review. --- .../src/container-usage-context.test.ts | 42 +++++++++++++++++++ .../src/container-usage-context.ts | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/services/cloud-agent-next/src/container-usage-context.test.ts b/services/cloud-agent-next/src/container-usage-context.test.ts index d29233e8c3..d546641eda 100644 --- a/services/cloud-agent-next/src/container-usage-context.test.ts +++ b/services/cloud-agent-next/src/container-usage-context.test.ts @@ -3,13 +3,16 @@ import type { SandboxId, SandboxInstance } from './types.js'; import type { SessionMetadata } from './persistence/session-metadata.js'; import { assertSandboxBillingAllocation, + billingCapacityForSandboxClass, buildSandboxBillingInput, configureSandboxBillingInput, containersBillingIdentity, forceDestroyControlPlaneSandbox, getSandboxBillingRuntimeStatus, + isContainersBillingClassName, SANDBOX_CAPACITIES, SANDBOX_USAGE_SKUS, + type SandboxClassName, } from './container-usage-context.js'; function metadata(identity: SessionMetadata['identity']): SessionMetadata { @@ -146,6 +149,45 @@ describe('container usage context', () => { } }); + it('does not classify inherited object keys as containers billing classes', () => { + for (const className of ['toString', 'constructor', 'valueOf', '__proto__'] as const) { + expect(isContainersBillingClassName(className as SandboxClassName)).toBe(false); + } + }); + + it('classifies exactly the own containers billing classes', () => { + for (const className of ['SandboxContainersStandard3', 'SandboxContainersStandard4'] as const) { + expect(isContainersBillingClassName(className)).toBe(true); + } + for (const className of [ + 'Sandbox', + 'SandboxContainment', + 'SandboxSmall', + 'SandboxSmallContainment', + 'SandboxDIND', + 'SandboxCodeReview', + 'SandboxCodeReviewContainment', + ] as const) { + expect(isContainersBillingClassName(className)).toBe(false); + } + }); + + it('agrees with containers identity resolution for every resolved class name', () => { + for (const instance of ['standard-3', 'standard-4'] as const) { + const { className } = containersBillingIdentity(instance); + expect(isContainersBillingClassName(className)).toBe(true); + } + }); + + it('never resolves an inherited object key as a sandbox billing capacity', () => { + for (const className of ['toString', 'constructor', 'valueOf'] as const) { + expect(isContainersBillingClassName(className as SandboxClassName)).toBe(false); + expect(billingCapacityForSandboxClass(className as SandboxClassName)).not.toHaveProperty( + 'vcpu' + ); + } + }); + it('accepts a containers class against an isolated `ses` billing ID and rejects a bare ID', () => { const containersClasses = ['SandboxContainersStandard3', 'SandboxContainersStandard4'] as const; const attribution = { diff --git a/services/cloud-agent-next/src/container-usage-context.ts b/services/cloud-agent-next/src/container-usage-context.ts index 17f03134f9..8a7d7ad4d6 100644 --- a/services/cloud-agent-next/src/container-usage-context.ts +++ b/services/cloud-agent-next/src/container-usage-context.ts @@ -65,7 +65,7 @@ export function usageServiceForSandboxClass(sandboxClassName: SandboxClassName): export function isContainersBillingClassName( sandboxClassName: SandboxClassName ): sandboxClassName is ContainersBillingClassName { - return sandboxClassName in CONTAINERS_BILLING_CAPACITIES; + return Object.hasOwn(CONTAINERS_BILLING_CAPACITIES, sandboxClassName); } export function billingCapacityForSandboxClass(