Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 46 additions & 24 deletions services/cloud-agent-next/src/persistence/SandboxControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ import {
getSandboxNamespace,
} from '../sandbox-id.js';
import {
validateContainersTerminalBillingRuntime,
validateTerminalBillingRuntime,
type SandboxTerminalAccessInput,
type SandboxTerminalAccessResult,
Expand Down Expand Up @@ -2610,35 +2611,56 @@ export class SandboxControl extends DurableObject<Env> {
...(input.organizationId ? { orgId: input.organizationId } : {}),
});
if (!enforced) return this.renewTerminalCredentialLease(input, runtime);
if (runtime.provider !== 'cloudflare') {
if (runtime.provider !== 'cloudflare' && runtime.provider !== 'cloudflare-containers') {
return { allowed: false, reason: 'billing_policy_unavailable' };
}

let billing: SandboxTerminalAccessResult;
try {
const providerRef = decodeCloudflareProviderRef(canonicalProviderRefOf(runtime.physical));
if (!providerRef) return { allowed: false, reason: 'runtime_not_running' };
const allocationId = providerRef.sandboxId;
const namespace = getSandboxNamespace(this.env, allocationId, {
managedScmContainment: providerRef.containment,
});
const sandbox = getSandbox(namespace, allocationId);
billing = validateTerminalBillingRuntime({
access: runtime.route.worktreeId
? {
...input,
sessionId: `workspace_${runtime.route.worktreeId.slice('worktree_'.length)}`,
}
: input,
sandboxId: allocationId,
providerInstanceId: runtime.connection.providerInstanceId,
sandboxDurableObjectId: namespace.idFromName(allocationId).toString(),
runtime: await withTimeout(
getSandboxBillingRuntimeStatus(sandbox),
DEADLINE_MS.stopAttempt,
'Sandbox billing runtime observation timed out'
),
});
if (runtime.provider === 'cloudflare') {
const providerRef = decodeCloudflareProviderRef(canonicalProviderRefOf(runtime.physical));
if (!providerRef) return { allowed: false, reason: 'runtime_not_running' };
const allocationId = providerRef.sandboxId;
const namespace = getSandboxNamespace(this.env, allocationId, {
managedScmContainment: providerRef.containment,
});
const sandbox = getSandbox(namespace, allocationId);
billing = validateTerminalBillingRuntime({
access: runtime.route.worktreeId
? {
...input,
sessionId: `workspace_${runtime.route.worktreeId.slice('worktree_'.length)}`,
}
: input,
sandboxId: allocationId,
providerInstanceId: runtime.connection.providerInstanceId,
sandboxDurableObjectId: namespace.idFromName(allocationId).toString(),
runtime: await withTimeout(
getSandboxBillingRuntimeStatus(sandbox),
DEADLINE_MS.stopAttempt,
'Sandbox billing runtime observation timed out'
),
});
} else {
const namespace = this.env.SANDBOX_CONTAINERS;
const container = namespace.getByName(this.sandboxId);
billing = validateContainersTerminalBillingRuntime({
access: runtime.route.worktreeId
? {
...input,
sessionId: `workspace_${runtime.route.worktreeId.slice('worktree_'.length)}`,
}
: input,
sandboxId: this.sandboxId,
providerInstanceId: runtime.connection.providerInstanceId,
sandboxDurableObjectId: namespace.idFromName(this.sandboxId).toString(),
runtime: await withTimeout(
container.getBillingRuntimeStatus(),
DEADLINE_MS.stopAttempt,
'Sandbox billing runtime observation timed out'
),
});
}
} catch {
return { allowed: false, reason: 'billing_runtime_unavailable' };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CLOUDFLARE_CONTAINERS_DEFAULT_INSTANCE } from '@kilocode/worker-utils/sandbox-allocation';
import { parseSandboxBillingInput } from '../container-usage-context.js';
import {
parseSandboxBillingInput,
type SandboxBillingAdmissionResult,
} from '../container-usage-context.js';
import type {
ContainerInstanceSize,
ContainersObservation,
Expand Down Expand Up @@ -65,6 +68,16 @@ function createStub() {
stop: vi.fn(async (_ref: string): Promise<'terminal' | 'retryable'> => 'terminal'),
ensureLeaseAtLeast: vi.fn(async (_ref: string, _ms: number): Promise<void> => undefined),
readLog: vi.fn(async (_ref: string, _path: string, _bytes: number): Promise<string> => ''),
isBillingBlocked: vi.fn(async (): Promise<boolean> => false),
ensureBillingAdmission: vi.fn(
async (
_input: unknown,
_instance?: ContainerInstanceSize
): Promise<SandboxBillingAdmissionResult> => ({ success: true })
),
configureBilling: vi.fn(
async (_input: unknown, _instance?: ContainerInstanceSize): Promise<void> => undefined
),
};
}

Expand Down Expand Up @@ -142,14 +155,77 @@ describe('cloudflare containers provider create', () => {
expect(getContainer).not.toHaveBeenCalled();
});

it('fails closed when billing enforcement is requested', async () => {
const { adapter, getContainer } = setup();
it('resolved default instance admits standard-4 through the DO and startup uses standard-4', async () => {
const { adapter, stub } = setup();

await expect(
adapter.create(makeIntent({ billing: { ...billing, enforcementRequested: true } }))
).rejects.toMatchObject({ failure: 'billing_blocked' });
).resolves.toEqual({ providerRef: REF_A });

expect(getContainer).not.toHaveBeenCalled();
expect(stub.ensureBillingAdmission).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ sandboxId: LOGICAL_ID, enforcementRequested: true }),
CLOUDFLARE_CONTAINERS_DEFAULT_INSTANCE
);
expect(stub.configureBilling).not.toHaveBeenCalled();

await adapter.launch(REF_A, {});
expect(stub.launchWrapper).toHaveBeenCalledWith(
expect.objectContaining({ instance: CLOUDFLARE_CONTAINERS_DEFAULT_INSTANCE })
);
});

it('a real insufficient_credits rejection surfaces as billing_blocked', async () => {
const { adapter, stub } = setup();
stub.ensureBillingAdmission.mockResolvedValue({
success: false,
code: 'insufficient_credits',
message: 'Insufficient credits',
});

await expect(
adapter.create(makeIntent({ billing: { ...billing, enforcementRequested: true } }))
).rejects.toMatchObject({
name: 'AgentSandboxUnavailableError',
failure: 'billing_blocked',
});
});

it('normalizes a rejected admission RPC to a temporary unavailability failure', async () => {
const { adapter, stub } = setup();
stub.ensureBillingAdmission.mockRejectedValue(new Error('meter rpc exploded'));

await expect(
adapter.create(makeIntent({ billing: { ...billing, enforcementRequested: true } }))
).rejects.toMatchObject({
name: 'AgentSandboxUnavailableError',
failure: 'billing_blocked',
message: 'Container billing admission is temporarily unavailable',
});
});

it('configures shadow billing when enforcement is not requested', async () => {
const { adapter, stub } = setup();

await expect(adapter.create(makeIntent({ billing }))).resolves.toEqual({ providerRef: REF_A });

expect(stub.configureBilling).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ sandboxId: LOGICAL_ID }),
CLOUDFLARE_CONTAINERS_DEFAULT_INSTANCE
);
expect(stub.ensureBillingAdmission).not.toHaveBeenCalled();
});

it('admits through the DO when a persisted billing block is set without enforcement', async () => {
const { adapter, stub } = setup();
stub.isBillingBlocked.mockResolvedValue(true);

await expect(adapter.create(makeIntent({ billing }))).resolves.toEqual({ providerRef: REF_A });

expect(stub.ensureBillingAdmission).toHaveBeenCalledWith(
expect.objectContaining({ sandboxId: LOGICAL_ID }),
CLOUDFLARE_CONTAINERS_DEFAULT_INSTANCE
);
expect(stub.configureBilling).not.toHaveBeenCalled();
});

it('resolves when billing enforcement is not requested', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { CLOUDFLARE_CONTAINERS_DEFAULT_INSTANCE } from '@kilocode/worker-utils/sandbox-allocation';
import { AgentSandboxUnavailableError } from '../agent-sandbox/protocol.js';
import {
parseSandboxBillingInput,
type SandboxBillingAdmissionResult,
} from '../container-usage-context.js';
import type {
ContainerInstanceSize,
ContainersObservation,
Expand Down Expand Up @@ -69,11 +73,41 @@ export function createCloudflareContainersProviderAdapter(deps: {
};

const ensureBillingAdmission: ProviderAdapter['ensureBillingAdmission'] = async (
_ref,
ref,
billing
) => {
if (billing?.enforcementRequested) {
throw new AgentSandboxUnavailableError('Container billing unavailable', 'billing_blocked');
if (!billing) return;
const parsed = decodeOwnedProviderRef(ref);
if (!parsed) throw new Error('Invalid Cloudflare containers allocation');
const input = parseSandboxBillingInput(billing);
const container = deps.getContainer(deps.logicalSandboxId);
let blocked = false;
try {
blocked = await container.isBillingBlocked();
} catch {
blocked = input.enforcementRequested === true;
}
if (input.enforcementRequested || blocked) {
let admission: SandboxBillingAdmissionResult;
try {
admission = await container.ensureBillingAdmission(input, instance);
} catch {
admission = {
success: false,
code: 'meter_unavailable',
message: 'Container billing admission is unavailable',
};
}
if (!admission.success) {
throw new AgentSandboxUnavailableError(
admission.code === 'insufficient_credits' || admission.code === 'stopping'
? 'Container billing requires additional credits'
: 'Container billing admission is temporarily unavailable',
'billing_blocked'
);
}
} else {
await container.configureBilling(input, instance).catch(() => undefined);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,9 @@ async function harness(
stop: vi.fn(async () => 'terminal'),
ensureLeaseAtLeast: vi.fn(async () => undefined),
readLog: vi.fn(async () => ''),
ensureBillingAdmission: vi.fn(async () => ({ success: true })),
configureBilling: vi.fn(async () => undefined),
isBillingBlocked: vi.fn(async () => false),
};
const sandboxContainers = {
idFromName: (id: string) => ({ toString: () => `do:SANDBOX_CONTAINERS:${id}` }),
Expand Down
Loading