Files
PaperClipAI/server/src/__tests__/browser-use-connection.test.ts
T
DottaandPaperclip 0eaabe5c0b refactor(accounting): remove company serialization from paid settlement (#18917)
Settle paid operations through operation-scoped transactions. Preserve exact ledger evidence and replay while moving aggregate budget decisions to durable deferred work.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-11-04 07:29:28 -06:00

1890 lines
91 KiB
TypeScript

import { budgetService, computeObservedSpend, flushBudgetEvaluationWork } from "../services/budgets.js";
import * as costAccounting from "../services/costs.js";
import { buildPaperclipRuntimeMcpServers } from "../services/heartbeat.js";
import { resolveNativeRuntimeMcpSnapshot } from "../services/native-runtime/runtime-context.js";
import express from "express";
import http from "supertest";
import { browserUseRoutes } from "../routes/browser-use.js";
import { errorHandler } from "../middleware/index.js";
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
approvals,
toolActionRequests,
toolInvocations,
toolPolicies,
companies,
companyMemberships,
connectionGrants,
createDb,
heartbeatRuns,
issues,
projects,
toolConnections,
toolApplications,
toolCatalogEntries,
toolConnectionInstalls,
toolProfiles,
toolProfileBindings,
toolProfileEntries,
browserUseSessions,
browserUseRuns,
browserUseBrowsers,
costEvents,
financeEvents,
budgetPolicies,
budgetReservations,
} from "@paperclipai/db";
import { toolAccessService } from "../services/tool-access.js";
import { createToolGatewayService } from "../services/tool-gateway.js";
import { browserUseViewports } from "../services/browser-use-viewport.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { registerBrowserUseCleanup } from "../services/browser-use-work.js";
import { browserUseService } from "../services/browser-use.js";
import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "../services/connector-runtime.js";
import { registerAssignedMcpGateway } from "../services/native-runtime/assigned-mcp-tools.js";
import { listPaperclipSkillEntries } from "@paperclipai/adapter-utils/server-utils";
import { fileURLToPath } from "node:url";
import path from "node:path";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
(support.supported ? describe : describe.skip)(
"Browser Use connection and lifecycle",
() => {
let db: ReturnType<typeof createDb>;
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
const originalApiUrl = process.env.PAPERCLIP_API_URL;
beforeAll(async () => {
process.env.PAPERCLIP_API_URL = "http://127.0.0.1:3100";
temp = await startEmbeddedPostgresTestDatabase("paperclip-browser-use-");
db = createDb(temp.connectionString);
}, 30000);
afterAll(async () => {
await temp?.cleanup();
if (originalApiUrl === undefined) delete process.env.PAPERCLIP_API_URL;
else process.env.PAPERCLIP_API_URL = originalApiUrl;
});
async function fixture(personal = false) {
const [company] = await db
.insert(companies)
.values({
name: "Browser fixture",
issuePrefix: `BU${randomUUID().slice(0, 6)}`,
})
.returning();
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: actor.actorId,
membershipRole: "admin",
status: "active",
});
const providerSession = randomUUID(),
providerBrowser = randomUUID();
let providerRun = randomUUID();
let acceptedTask = "", loseReply = false, rejectCode = 0, recoveryPages = 0;
let createGate: (() => Promise<void>) | undefined;
let totalCostUsd = "0.15";
let status = "running",
stopped = false,
browserReady = false,
invalidKey = false,
failCreate = false,
paginatedEvents = false;
const request = vi.fn(async (url: string, init: RequestInit) => {
expect(new Headers(init.headers).get("X-Browser-Use-API-Key")).toBe(
"bu_fixture_secret",
);
if (invalidKey)
return new Response("bu_fixture_secret private-error", {
status: 401,
});
const path = new URL(url).pathname.replace("/api/v4", "");
if (path === "/profiles")
return Response.json({ items: [], totalItems: 0 });
if (path === "/runs" && init.method === "GET") {
const cursor = Number(new URL(url).searchParams.get("cursor") ?? 0);
return Response.json(cursor < recoveryPages
? { runs: [], hasMore: true, nextCursor: String(cursor + 1) }
: { runs: acceptedTask ? [{ id: providerRun, sessionId: providerSession, task: acceptedTask, status }] : [], hasMore: false });
}
if (path === "/runs" && init.method === "POST" && rejectCode)
return new Response("Rejected", { status: rejectCode, headers: { "Retry-After": "1" } });
if (path === "/runs" && init.method === "POST" && failCreate)
throw new Error("lost create response");
if (path === "/runs" && init.method === "POST") {
await createGate?.();
providerRun = randomUUID();
status = "running";
acceptedTask = JSON.parse(String(init.body)).task;
if (loseReply) { browserReady = true; throw new Error("lost accepted create response"); }
return Response.json({
id: providerRun,
sessionId: providerSession,
status,
model: "fixture",
});
}
if (path.endsWith("/cancel")) {
status = "cancelled";
return Response.json({});
}
if (path.endsWith("/status")) return Response.json({ status });
if (path.endsWith("/events") && paginatedEvents) {
const next = Number(new URL(url).searchParams.get("after")) + 1;
return Response.json({
events: [{ id: next, type: "agent.step", data: {} }],
nextAfter: next,
hasMore: next < 9,
});
}
if (path.endsWith("/events"))
return Response.json({
events: browserReady
? [
{
id: 1,
type: "browser.ready",
data: {
live_view_url: "https://live.browser-use.com/canary",
},
},
]
: [],
nextAfter: browserReady ? 1 : 0,
hasMore: false,
});
if (path === `/runs/${providerRun}`)
return Response.json({
status,
result: "Finished https://live.browser-use.com/canary",
totalCostUsd,
model: "fixture",
});
if (path === "/browsers")
return Response.json({
items: browserReady
? [
{
id: providerBrowser,
status: stopped ? "stopped" : "active",
},
]
: [],
totalItems: browserReady ? 1 : 0,
});
if (path === `/browsers/${providerBrowser}`) {
if (init.method === "PATCH") stopped = true;
return Response.json({
id: providerBrowser,
agentSessionId: providerSession,
status: stopped ? "stopped" : "active",
liveUrl: "https://live.browser-use.com/canary",
cdpUrl: `https://${providerBrowser}.cdp.browser-use.com`,
timeoutAt: new Date(Date.now() + 3600000).toISOString(),
});
}
throw new Error(`Unexpected fixture path ${path}`);
});
const access = toolAccessService(db, {
remoteHttpRequest: request,
remoteHttpEndpointLookup: async () => [
{ address: "8.8.8.8", family: 4 },
],
});
const connection = await access.connectGalleryApp(
company.id,
{
galleryKey: "browser-use-cloud",
...(personal ? { grantKind: "user" as const } : {}),
connectionMethodKey: "cloud-v4",
credentialValues: { "credentials.apiKey": "bu_fixture_secret" },
},
actor,
);
const [grant] = await db
.select()
.from(connectionGrants)
.where(eq(connectionGrants.connectionId, connection.connectionId));
const [agent] = await db
.insert(agents)
.values({
companyId: company.id,
name: "Browser agent",
role: "engineer",
adapterType: "process",
adapterConfig: {},
})
.returning();
await access.finishGalleryAppConnection(
company.id,
connection.connectionId,
{
enabledCatalogEntryIds: connection.catalog.map((t) => t.id),
askFirstCatalogEntryIds: [],
access: { agentIds: [agent.id] },
},
actor,
);
const [issue] = await db
.insert(issues)
.values({
companyId: company.id,
title: "Browser task",
assigneeAgentId: agent.id,
})
.returning();
const [run] = await db
.insert(heartbeatRuns)
.values({
companyId: company.id,
agentId: agent.id,
invocationSource: "on_demand",
status: "running",
responsibleUserId: actor.actorId,
contextSnapshot: { issueId: issue.id },
})
.returning();
const [profile] = await db
.insert(toolProfiles)
.values({
companyId: company.id,
name: "Browser tools",
profileKey: randomUUID(),
defaultAction: "allow",
})
.returning();
await db.insert(toolProfileBindings).values({
companyId: company.id,
profileId: profile.id,
targetType: "agent",
targetId: agent.id,
});
const gateway = createToolGatewayService(db, {
remoteHttpRequest: request,
toolActionSigningSecret: "browser-fixture-signing-secret",
});
registerAssignedMcpGateway(db, gateway);
const gatewaySession = await gateway.createSession({
companyId: company.id,
agentId: agent.id,
runId: run.id,
});
const service = browserUseService(db, request);
const binding = {
companyId: company.id,
agentId: agent.id,
issueId: issue.id,
runId: run.id,
};
const tick = async () => {
await db
.update(browserUseSessions)
.set({ nextPollAt: new Date(0) })
.where(eq(browserUseSessions.companyId, company.id));
await service.sweep(connection.connectionId);
await flushBudgetEvaluationWork(db, { companyId: company.id });
};
return {
company,
connection,
access,
grant,
agent,
issue,
run,
service,
binding,
gateway,
gatewaySession,
request,
tick,
ready: () => {
browserReady = true;
},
complete: () => {
status = "completed";
},
invalidate: () => {
invalidKey = true;
},
failCreate: () => {
failCreate = true;
},
loseReply: () => { loseReply = true; },
reject: (code: number) => { rejectCode = code; },
recoveryPages: (count: number) => { recoveryPages = count; },
delayCreate: (gate: () => Promise<void>) => { createGate = gate; },
usage: (value: string) => { totalCostUsd = value; },
paginate: () => {
paginatedEvents = true;
},
};
}
it("wakes the app worker from request-service commits and restores durable poll and lease deadlines", async () => {
const f = await fixture();
const background = browserUseService(db, f.request);
const sweep = vi.spyOn(background, "sweep");
const onError = vi.fn();
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
try {
await registerBrowserUseCleanup(coordinator, background, () => true).ready;
expect(coordinator.nextWakeAt()).toBeNull();
expect(await background.nextSweepAt()).toBeNull();
const started = await f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Read example.com" }) as { sessionId: string };
await vi.waitFor(() => expect(sweep.mock.calls.length).toBeGreaterThan(1));
await coordinator.stop();
expect(onError).not.toHaveBeenCalled();
const poll = new Date(Date.now() + 60_000);
const lease = new Date(Date.now() + 180_000);
await db.update(browserUseSessions).set({ nextPollAt: poll, leaseUntil: lease })
.where(eq(browserUseSessions.id, started.sessionId));
expect(await background.nextSweepAt()).toBe(lease.getTime());
const restarted = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
try {
await registerBrowserUseCleanup(restarted, browserUseService(db, f.request), () => true).ready;
expect(restarted.nextWakeAt()).toBe(lease.getTime());
} finally { await restarted.stop(); }
await db.update(browserUseSessions).set({ leaseUntil: new Date(0) })
.where(eq(browserUseSessions.id, started.sessionId));
expect(await background.nextSweepAt()).toBe(poll.getTime());
await db.update(browserUseSessions).set({ leaseUntil: null })
.where(eq(browserUseSessions.id, started.sessionId));
expect(await background.nextSweepAt()).toBe(poll.getTime());
const [session] = await db.select().from(browserUseSessions).where(eq(browserUseSessions.id, started.sessionId));
await f.service.control(session, "end");
expect(await background.nextSweepAt()).toBeLessThan(poll.getTime());
await db.update(browserUseSessions).set({ status: "closed" }).where(eq(browserUseSessions.id, started.sessionId));
expect(await background.nextSweepAt()).toBeNull();
} finally {
await coordinator.stop();
await db.update(browserUseSessions).set({ status: "closed" }).where(eq(browserUseSessions.companyId, f.company.id));
}
});
it("keeps Cloud instructions out of universal skills and unassigned runtime overlays", async () => {
const skills = await listPaperclipSkillEntries(fileURLToPath(new URL("../", import.meta.url)), [fileURLToPath(new URL("../../../skills", import.meta.url))]);
expect(skills.some(skill => skill.runtimeName === "paperclip")).toBe(true);
expect(skills.some(skill => ["browser-use", "browser-use-cloud"].includes(skill.runtimeName))).toBe(false);
const base = { paperclipSkillSync: { desiredSkills: ["paperclipai/paperclip/browser-use", "paperclipai/paperclip/browser-use-cloud"] } };
const unassigned = await applyConnectorSkills(base, [
{ key: "paperclipai/paperclip/browser-use", runtimeName: "browser-use", source: "/retired-browser-skill" },
{ key: "paperclipai/paperclip/browser-use-cloud", runtimeName: "browser-use-cloud", source: "/unassigned-cloud-skill" },
{ key: "custom/browser-use", runtimeName: "browser-use", source: "/another-browser-skill" },
], []);
expect(unassigned.paperclipRuntimeSkills).toEqual([{ key: "custom/browser-use", runtimeName: "browser-use", source: "/another-browser-skill" }]);
expect(unassigned.paperclipConnectorSkillDigest).toBeNull();
expect(base.paperclipSkillSync.desiredSkills).toHaveLength(2);
});
it("delivers the Cloud skill only with authorized connection tools in a task run", async () => {
const f = await fixture();
const [unassignedAgent] = await db.insert(agents).values({
companyId: f.company.id,
name: "Agent without the Cloud connection",
role: "engineer",
adapterType: "process",
adapterConfig: {},
}).returning();
expect(await f.gateway.browserUseResources({ companyId: f.company.id, agentId: f.agent.id })).toEqual([]);
expect(await resolveConnectorAssignments(db, { ...f.binding, agentId: unassignedAgent.id })).toEqual([]);
expect(await resolveConnectorAssignments(db, { ...f.binding, companyId: randomUUID() })).toEqual([]);
const assignments = await resolveConnectorAssignments(db, f.binding);
expect(assignments).toMatchObject([{ key: "browser-use-cloud", skillKey: "paperclipai/paperclip/browser-use-cloud", resources: [{ connectionId: f.connection.connectionId }] }]);
const config = await applyConnectorSkills({}, [], assignments);
const skill = config.paperclipRuntimeSkills[0];
expect(skill.runtimeName).toBe("browser-use-cloud");
const markdown = await readFile(path.join(skill.source, "SKILL.md"), "utf8");
expect(markdown).toContain("name: browser-use-cloud");
expect(markdown).toContain("browser_start");
expect(markdown).toContain(f.connection.connectionId);
expect(markdown).not.toContain("bu_fixture_secret");
for (const adapterType of ["paperclip_runner", "codex_local", "claude_local", "kimi_local"]) {
const delivery = await prepareConnectorSkillDelivery({ ...config, engine: "cli" }, adapterType);
expect(delivery.config.paperclipRuntimeSkills).toEqual([skill]);
}
const sharedHome = await prepareConnectorSkillDelivery(config, "cursor_local");
expect(sharedHome.config.paperclipRuntimeSkills).toEqual([]);
expect(sharedHome.instructions).toContain("name: browser-use-cloud");
expect(sharedHome.config.paperclipConnectorSkillDigest).toBe(config.paperclipConnectorSkillDigest);
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.grant.id));
expect(await resolveConnectorAssignments(db, f.binding)).toEqual([]);
const revoked = await applyConnectorSkills(config, config.paperclipRuntimeSkills, []);
expect(revoked.paperclipRuntimeSkills).toEqual([]);
expect(revoked.paperclipConnectorSkillDigest).toBeNull();
expect((await prepareConnectorSkillDelivery(revoked, "cursor_local")).instructions).toBe("");
});
it.each([false, true])("skips tool discovery without an active company Browser Use connection (foreign: %s)", async foreign => {
const f = await fixture();
await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.connection.connectionId));
if (foreign) await fixture();
f.request.mockClear();
const discovery = vi.spyOn(db, "transaction");
try {
expect(await f.gateway.browserUseResources(f.binding)).toEqual([]);
expect(discovery).not.toHaveBeenCalled();
expect(f.request).not.toHaveBeenCalled();
} finally { discovery.mockRestore(); }
});
it("resolves only the Browser Use catalog even while full discovery is saturated", async () => {
const f = await fixture();
const [application] = await db.insert(toolApplications).values({
companyId: f.company.id, name: "Unrelated MCP", type: "mcp_http", status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: f.company.id, applicationId: application.id, name: "Unrelated MCP",
uid: randomUUID(), transport: "mcp_remote", status: "active", enabled: true, healthStatus: "ok",
}).returning();
await db.insert(toolCatalogEntries).values(Array.from({ length: 500 }, (_, i) => ({
companyId: f.company.id, applicationId: application.id, connectionId: connection.id,
entryKind: "tool" as const, name: `unrelated_${i}`, toolName: `unrelated_${i}`,
inputSchema: { type: "object" }, riskLevel: "read" as const, status: "active" as const,
versionHash: "fixture", schemaHash: "fixture",
})));
const { recordingDb } = await import("./helpers/tool-gateway-listing-fixture.js");
const { toolDiscoveryScheduler } = await import("../services/tool-discovery-scheduler.js");
const releases: Array<() => void> = [];
const blockers = [0, 1].map(() => toolDiscoveryScheduler.run(() => new Promise<void>(resolve => releases.push(resolve))));
await vi.waitFor(() => expect(releases).toHaveLength(2));
const recorded = recordingDb(db);
const gateway = createToolGatewayService(recorded.db);
const pending = gateway.browserUseResources(f.binding);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const resources = await Promise.race([pending, new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error("Assignment waited for unrelated discovery")), 5_000);
})]);
expect(resources).toEqual([{ id: f.connection.connectionId, connectionId: f.connection.connectionId, label: "Browser Use Cloud" }]);
const catalogReads = recorded.statements.flatMap((query, index) =>
query.includes('from "tool_catalog_entries"') && query.includes('inner join "tool_connections"') ? [recorded.statementParams[index]] : []);
expect(catalogReads).toHaveLength(1);
expect(catalogReads[0]).toContain(f.connection.connectionId);
expect(catalogReads[0]).not.toContain(connection.id);
expect(recorded.statements.some(query => query.includes('from "chat_endpoints"'))).toBe(false);
} finally {
clearTimeout(timeout);
releases.forEach(release => release());
await Promise.all(blockers);
await pending;
}
});
it.each(["policy", "catalog", "grant"])("rechecks Browser Use %s revocation without a cached assignment", async kind => {
const f = await fixture();
expect(await f.gateway.browserUseResources(f.binding)).toHaveLength(1);
if (kind === "policy") await db.insert(toolPolicies).values({
companyId: f.company.id, name: "Revoke Browser Use", policyType: "block",
selectors: { connectionId: f.connection.connectionId }, priority: 100,
});
if (kind === "catalog") await db.update(toolCatalogEntries).set({ quarantinedAt: new Date() }).where(eq(toolCatalogEntries.connectionId, f.connection.connectionId));
if (kind === "grant") await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.grant.id));
expect(await f.gateway.browserUseResources(f.binding)).toEqual([]);
});
it("withholds Cloud instructions when connection tools are disabled", async () => {
const f = await fixture();
await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.connection.connectionId));
expect(await resolveConnectorAssignments(db, f.binding)).toEqual([]);
});
it("reapplies the renumbered migration without losing existing browser work", async () => {
const f = await fixture();
await f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", {
task: "Read example.com",
maxCostUsd: 0.5,
});
f.ready(); f.complete(); await f.tick();
const previous = await db.select().from(browserUseRuns)
.where(eq(browserUseRuns.companyId, f.company.id));
expect(previous).toHaveLength(1);
const [original] = await db.select().from(toolConnections).where(eq(toolConnections.id, f.connection.connectionId));
await db.update(toolConnections).set({
config: { ...original.config, sourceTemplateKey: "browser-use" },
transportConfig: { ...original.transportConfig, sourceTemplateKey: "browser-use" },
}).where(eq(toolConnections.id, original.id));
await db.update(toolApplications).set({
applicationKey: `app-gallery:browser-use:${original.id}`,
metadata: { sourceTemplateKey: "browser-use", galleryKey: "browser-use" },
}).where(eq(toolApplications.id, original.applicationId));
const legacyCost = { provider: "browser-use", biller: "browser-use", billingCode: `browser-use:${previous[0].id}` };
await db.update(costEvents).set(legacyCost).where(eq(costEvents.companyId, f.company.id));
await db.update(financeEvents).set(legacyCost).where(eq(financeEvents.companyId, f.company.id));
const migration = await readFile(new URL(
"../../../packages/db/src/migrations/0290_browser_use_cloud.sql", import.meta.url,
), "utf8");
for (let attempt = 0; attempt < 2; attempt++) {
for (const statement of migration.split("--> statement-breakpoint")) {
if (statement.trim()) await db.execute(sql.raw(statement));
}
}
expect(await db.select().from(browserUseRuns)
.where(eq(browserUseRuns.companyId, f.company.id))).toEqual(previous);
const [migrated] = await db.select().from(toolConnections).where(eq(toolConnections.id, original.id));
expect(migrated).toMatchObject({ config: { sourceTemplateKey: "browser-use-cloud" }, transportConfig: { sourceTemplateKey: "browser-use-cloud" }, credentialSecretRefs: original.credentialSecretRefs });
expect(await db.select().from(toolApplications).where(eq(toolApplications.id, original.applicationId))).toMatchObject([{ applicationKey: `app-gallery:browser-use-cloud:${original.id}`, metadata: { sourceTemplateKey: "browser-use-cloud", galleryKey: "browser-use-cloud" } }]);
expect(await resolveConnectorAssignments(db, f.binding)).toMatchObject([{ key: "browser-use-cloud" }]);
await f.tick();
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toMatchObject([{ provider: "browser-use-cloud", biller: "browser-use-cloud", billingCode: `browser-use-cloud:${previous[0].id}`, costCents: 15 }]);
expect(await db.select().from(financeEvents).where(eq(financeEvents.companyId, f.company.id))).toMatchObject([{ provider: "browser-use-cloud", amountCents: 15 }]);
});
it("delivers Browser Use through the pinned native and CLI runtime gateway", async () => {
const f = await fixture();
await db.insert(toolConnectionInstalls).values({
companyId: f.company.id,
connectionId: f.connection.connectionId,
targetType: "agent",
targetId: f.agent.id,
});
const snapshot = await resolveNativeRuntimeMcpSnapshot({
db,
agent: f.agent,
runId: f.run.id,
});
expect(snapshot.bindingId).not.toBeNull();
const servers = await buildPaperclipRuntimeMcpServers({
db,
agent: f.agent,
runId: f.run.id,
expectedAssignmentDigest: snapshot.digest,
});
expect(servers).toHaveLength(1);
const gatewayPublicId = new URL(servers[0]!.url).pathname
.split("/")
.at(-1)!;
const tools = await f.gateway.listToolsForNamedGateway({
gatewayPublicId,
bearerToken: servers[0]!.token!,
});
expect(tools).toHaveLength(7);
expect(tools.some((tool) => tool.name.endsWith(":browser-start"))).toBe(
true,
);
});
it("verifies credentials without paid work, exposes REST tools and rejects invalid keys", async () => {
const f = await fixture();
expect(f.connection.catalog).toHaveLength(7);
expect(
f.request.mock.calls.every(([url]) => url.includes("/profiles")),
).toBe(true);
expect(JSON.stringify(f.connection)).not.toContain("bu_fixture_secret");
const tools = await f.gateway.listToolsForSession(f.gatewaySession.token);
expect(
tools.find((t) => t.upstreamToolName === "browser_start"),
).toMatchObject({ providerType: "provider_rest", risk: "destructive" });
const start = tools.find((t) => t.upstreamToolName === "browser_start")!;
const idempotencyKey = randomUUID();
const result = await f.gateway.executeTool({
sessionToken: f.gatewaySession.token,
tool: start.name,
parameters: { task: "Read example.com" },
idempotencyKey,
});
expect(result.status).toBe("completed");
expect(JSON.stringify(result)).not.toContain("bu_fixture_secret");
await f.gateway.executeTool({
sessionToken: f.gatewaySession.token,
tool: start.name,
parameters: { task: "Read example.com" },
idempotencyKey,
});
expect(
f.request.mock.calls.filter(
([url, init]) => url.endsWith("/runs") && init.method === "POST",
),
).toHaveLength(1);
f.invalidate();
await expect(
f.access.refreshCatalog(f.connection.connectionId, actor),
).rejects.toThrow();
});
it("observes delayed browser.ready, hides viewer credentials, accounts once and cleans up", async () => {
const f = await fixture();
const invocation = randomUUID();
const started = (await f.service.execute(
f.binding,
f.grant,
invocation,
"browser_start",
{ task: "Read example.com", maxCostUsd: 0.5 },
)) as { sessionId: string };
expect(started.sessionId).toBeTruthy();
const createCall = f.request.mock.calls.find(
([url, init]) => url.endsWith("/runs") && init.method === "POST",
);
expect(
JSON.parse(String(createCall?.[1].body)).browserSettings.allowResizing,
).toBe(true);
await f.tick();
expect(
await db
.select()
.from(browserUseBrowsers)
.where(eq(browserUseBrowsers.companyId, f.company.id)),
).toHaveLength(0);
f.ready();
await f.tick();
const list = await f.service.list(
f.company.id,
f.issue.id,
actor.actorId,
);
expect(list).toHaveLength(1);
expect(JSON.stringify(list)).not.toContain("canary");
expect(
await f.service.viewer(
f.company.id,
f.issue.id,
list[0].id,
actor.actorId,
),
).toEqual({
url: "https://live.browser-use.com/canary",
viewport: "fit",
viewportState: { preset: "fit" },
});
await expect(
f.service.viewer(f.company.id, randomUUID(), list[0].id, actor.actorId),
).rejects.toThrow("not found");
f.complete();
await f.tick();
await f.tick();
const [s] = await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.id, started.sessionId));
expect(s.status).toBe("idle");
expect(s.idleDeadline).toBeTruthy();
expect(
await db
.select()
.from(costEvents)
.where(eq(costEvents.companyId, f.company.id)),
).toHaveLength(1);
const ledger = await db
.select()
.from(financeEvents)
.where(eq(financeEvents.companyId, f.company.id));
const [cost] = await db
.select()
.from(costEvents)
.where(eq(costEvents.companyId, f.company.id));
expect(ledger).toHaveLength(1);
expect(ledger[0]).toMatchObject({
issueId: f.issue.id,
agentId: f.agent.id,
costEventId: cost.id,
amountCents: 15,
biller: "browser-use-cloud",
direction: "debit",
currency: "USD",
});
expect(
JSON.stringify(
await f.service.execute(
f.binding,
f.grant,
randomUUID(),
"browser_status",
{ sessionId: s.id },
),
),
).not.toContain("canary");
await f.service.control(s, "end");
await f.tick();
expect(
(
await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.id, s.id))
)[0].status,
).toBe("closed");
expect(
(
await db
.select()
.from(browserUseBrowsers)
.where(eq(browserUseBrowsers.sessionId, s.id))
)[0].status,
).toBe("stopped");
});
it("keeps a viewed idle browser alive but still honors expiry, stop and authorization", async () => {
const f = await fixture();
const started = (await f.service.execute(
f.binding,
f.grant,
randomUUID(),
"browser_start",
{ task: "Read" },
)) as { sessionId: string };
f.ready();
f.complete();
await f.tick();
const [browser] = await f.service.list(
f.company.id,
f.issue.id,
actor.actorId,
);
const read = async () =>
(
await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.id, started.sessionId))
)[0];
const original = new Date(Date.now() + 1_000);
await db
.update(browserUseSessions)
.set({ idleDeadline: original })
.where(eq(browserUseSessions.id, started.sessionId));
await expect(
f.service.presence(
f.company.id,
randomUUID(),
browser.id,
actor.actorId,
),
).rejects.toThrow();
expect((await read()).idleDeadline?.getTime()).toBe(original.getTime());
expect(
await f.service.presence(
f.company.id,
f.issue.id,
browser.id,
actor.actorId,
),
).toEqual({ accepted: true });
expect((await read()).idleDeadline!.getTime()).toBeGreaterThan(
Date.now() + 9 * 60_000,
);
await f.tick();
expect((await read()).status).toBe("idle");
// Provider lifetime wins over viewer presence.
const expiry = new Date(Date.now() + 3 * 60_000);
await db
.update(browserUseBrowsers)
.set({ expiresAt: expiry })
.where(eq(browserUseBrowsers.id, browser.id));
await db
.update(browserUseSessions)
.set({ idleDeadline: original })
.where(eq(browserUseSessions.id, started.sessionId));
await f.service.presence(
f.company.id,
f.issue.id,
browser.id,
actor.actorId,
);
expect((await read()).idleDeadline?.getTime()).toBe(expiry.getTime());
await f.service.control(await read(), "end");
await expect(
f.service.presence(f.company.id, f.issue.id, browser.id, actor.actorId),
).rejects.toThrow();
await f.tick();
expect((await read()).status).toBe("closed");
});
it("executes an approved start once after the original turn ends and attaches the resumed run", async () => {
const f = await fixture();
await db.insert(toolPolicies).values({
companyId: f.company.id,
name: "Review browser writes",
policyType: "require_approval",
selectors: { connectionId: f.connection.connectionId },
priority: 10,
});
const start = (
await f.gateway.listToolsForSession(f.gatewaySession.token)
).find((t) => t.upstreamToolName === "browser_start")!;
await expect(
f.gateway.executeTool({
sessionToken: f.gatewaySession.token,
tool: start.name,
parameters: { task: "Read example.com" },
idempotencyKey: randomUUID(),
}),
).rejects.toMatchObject({ reasonCode: "approval_required" });
const [pending] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.companyId, f.company.id));
await db
.update(heartbeatRuns)
.set({ status: "succeeded" })
.where(eq(heartbeatRuns.id, f.run.id));
if (pending.approvalId)
await db
.update(approvals)
.set({
status: "approved",
decidedByUserId: actor.actorId,
decidedAt: new Date(),
})
.where(eq(approvals.id, pending.approvalId));
await f.gateway.approveActionRequest({
companyId: f.company.id,
actionRequestId: pending.id,
actor: { userId: actor.actorId },
});
const [record] = await db
.select()
.from(browserUseRuns)
.where(eq(browserUseRuns.companyId, f.company.id));
expect(record?.providerRunId).toBeTruthy();
expect(record.detachedUntil).toBeTruthy();
f.ready();
await f.tick();
expect(
(
await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.id, record.sessionId))
)[0].status,
).toBe("running");
const [resumed] = await db
.insert(heartbeatRuns)
.values({
companyId: f.company.id,
agentId: f.agent.id,
invocationSource: "on_demand",
status: "running",
responsibleUserId: actor.actorId,
contextSnapshot: { issueId: f.issue.id },
})
.returning();
await f.service.execute(
{ ...f.binding, runId: resumed.id },
f.grant,
randomUUID(),
"browser_status",
{ sessionId: record.sessionId },
);
const [attached] = await db
.select()
.from(browserUseRuns)
.where(eq(browserUseRuns.id, record.id));
expect(attached.detachedUntil).toBeNull();
expect(attached.heartbeatRunId).toBe(resumed.id);
await f.gateway.approveActionRequest({
companyId: f.company.id,
actionRequestId: pending.id,
actor: { userId: actor.actorId },
});
expect(
f.request.mock.calls.filter(
([url, init]) => url.endsWith("/runs") && init.method === "POST",
),
).toHaveLength(1);
});
it("enforces task, agent, grant and profile ownership and never replays a create", async () => {
const f = await fixture();
const invocation = randomUUID();
await expect(
f.service.execute(f.binding, f.grant, invocation, "browser_start", {
task: "Read",
profileId: randomUUID(),
}),
).rejects.toThrow("profile");
const start = (await f.service.execute(
f.binding,
f.grant,
invocation,
"browser_start",
{ task: "Read" },
)) as { sessionId: string };
await f.service.execute(f.binding, f.grant, invocation, "browser_start", {
task: "Read",
});
expect(
f.request.mock.calls.filter(
([url, init]) => url.endsWith("/runs") && init.method === "POST",
),
).toHaveLength(1);
await expect(
f.service.execute(
{ ...f.binding, issueId: randomUUID() },
f.grant,
randomUUID(),
"browser_status",
{ sessionId: start.sessionId },
),
).rejects.toThrow();
await expect(
f.service.execute(
f.binding,
{ ...f.grant, id: randomUUID() },
randomUUID(),
"browser_status",
{ sessionId: start.sessionId },
),
).rejects.toThrow();
await expect(
f.service.execute(
f.binding,
f.grant,
randomUUID(),
"browser_continue",
{ sessionId: start.sessionId, task: "Again" },
),
).rejects.toThrow("busy");
f.ready();
await f.tick();
await db
.update(connectionGrants)
.set({ status: "revoked" })
.where(eq(connectionGrants.id, f.grant.id));
expect(
await f.service.list(f.company.id, f.issue.id, actor.actorId),
).toEqual([]);
await f.tick();
expect(
(
await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.id, start.sessionId))
)[0].status,
).toBe("closed");
});
it("does not replay an uncertain paid create", async () => {
const f = await fixture();
f.failCreate();
const invocationId = randomUUID();
await expect(
f.service.execute(f.binding, f.grant, invocationId, "browser_start", {
task: "Read",
}),
).rejects.toThrow("could not be confirmed");
const result = await f.service.execute(
f.binding,
f.grant,
invocationId,
"browser_start",
{ task: "Read" },
);
expect(result).toMatchObject({ status: "stopping", runStatus: "unknown" });
expect(
f.request.mock.calls.filter(
([url, init]) => url.endsWith("/runs") && init.method === "POST",
),
).toHaveLength(1);
});
it.each([409, 429])("keeps a browser visible and closable after a rejected continuation (%s)", async (code) => {
const f = await fixture();
const start = await f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Read" }) as { sessionId: string };
f.ready(); f.complete(); await f.tick();
f.reject(code);
await expect(f.service.execute(f.binding, f.grant, randomUUID(), "browser_continue", { task: "Read again", sessionId: start.sessionId })).rejects.toThrow();
expect(await f.service.list(f.company.id, f.issue.id, actor.actorId)).toMatchObject([{ status: "idle" }]);
const [session] = await db.select().from(browserUseSessions).where(eq(browserUseSessions.id, start.sessionId));
await f.service.control(session, "end", actor.actorId);
// The 429 response delays the whole credential's provider requests.
if (code === 429) await new Promise(resolve => setTimeout(resolve, 1050));
await f.tick();
expect(await f.service.list(f.company.id, f.issue.id, actor.actorId)).toMatchObject([{ status: "closed" }]);
});
it("rolls back an interrupted rejection and closes the existing browser after restart", async () => {
const f = await fixture();
const start = await f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Read" }) as { sessionId: string };
f.ready(); f.complete(); await f.tick();
f.reject(409);
const invocationId = randomUUID();
const transaction = db.transaction.bind(db);
const interrupted = vi.spyOn(db, "transaction").mockImplementation((callback, config) =>
transaction(async tx => {
const result = await callback(tx);
const [run] = await tx.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocationId));
if (run?.status === "failed") throw new Error("process interrupted before commit");
return result;
}, config));
try {
await expect(f.service.execute(f.binding, f.grant, invocationId, "browser_continue", { task: "Read again", sessionId: start.sessionId })).rejects.toThrow("process interrupted");
} finally {
interrupted.mockRestore();
}
const [pending] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocationId));
expect(pending).toMatchObject({ status: "creating", eventsDrained: 0, providerRunId: null });
await db.update(browserUseRuns).set({ createdAt: new Date(Date.now() - 61_000) }).where(eq(browserUseRuns.id, pending.id));
await db.update(browserUseSessions).set({ nextPollAt: new Date(0) }).where(eq(browserUseSessions.id, start.sessionId));
await browserUseService(db, f.request).sweep();
expect(await db.select().from(browserUseBrowsers).where(eq(browserUseBrowsers.sessionId, start.sessionId))).toMatchObject([{ status: "stopped" }]);
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(2);
});
it("recovers an accepted start after a lost response and restart, then stops and accounts without replay", async () => {
const f = await fixture();
f.loseReply(); f.recoveryPages(6);
await expect(f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Read" })).rejects.toThrow("could not be confirmed");
await f.tick();
const [pending] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.companyId, f.company.id));
expect(pending.recoveryCursor).toBe("5");
await db.update(browserUseSessions).set({ nextPollAt: new Date(0) }).where(eq(browserUseSessions.companyId, f.company.id));
await browserUseService(db, f.request).sweep();
expect(await f.service.list(f.company.id, f.issue.id, actor.actorId)).toMatchObject([{ status: "closed" }]);
expect(await db.select().from(financeEvents).where(eq(financeEvents.companyId, f.company.id))).toMatchObject([{ amountCents: 15 }]);
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(1);
});
it("recovers a continuation's lost reply within its existing provider session", async () => {
const f = await fixture();
const start = await f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Read" }) as { sessionId: string };
f.ready(); f.complete(); await f.tick();
f.loseReply();
await expect(f.service.execute(f.binding, f.grant, randomUUID(), "browser_continue", { task: "Read again", sessionId: start.sessionId })).rejects.toThrow("could not be confirmed");
await f.tick();
expect(await f.service.list(f.company.id, f.issue.id, actor.actorId)).toMatchObject([{ status: "closed" }]);
expect(f.request.mock.calls.some(([url]) => new URL(url).searchParams.has("sessionId"))).toBe(true);
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(2);
});
it("recovers a process crash before provider identifiers were saved", async () => {
const f = await fixture();
const start = await f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Read" }) as { sessionId: string };
f.ready();
await db.update(browserUseRuns).set({ providerRunId: null, status: "creating", createdAt: new Date(Date.now() - 61_000) }).where(eq(browserUseRuns.sessionId, start.sessionId));
await db.update(browserUseSessions).set({ providerSessionId: null, status: "starting" }).where(eq(browserUseSessions.id, start.sessionId));
await f.tick();
expect(await f.service.list(f.company.id, f.issue.id, actor.actorId)).toMatchObject([{ status: "closed" }]);
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(1);
});
it("drains bounded event pages after restart before closing and accounting", async () => {
const f = await fixture();
const start = (await f.service.execute(
f.binding,
f.grant,
randomUUID(),
"browser_start",
{ task: "Read" },
)) as { sessionId: string };
f.ready();
await f.tick();
f.paginate();
f.complete();
const [session] = await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.id, start.sessionId));
await f.service.control(session, "end");
await f.tick();
expect(
(
await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.id, start.sessionId))
)[0].status,
).toBe("stopping");
expect(
await db
.select()
.from(costEvents)
.where(eq(costEvents.companyId, f.company.id)),
).toHaveLength(0);
await db
.update(browserUseSessions)
.set({ nextPollAt: new Date(0) })
.where(eq(browserUseSessions.id, start.sessionId));
await browserUseService(db, f.request).sweep();
expect(
(
await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.id, start.sessionId))
)[0].status,
).toBe("closed");
expect(
(
await db
.select()
.from(browserUseRuns)
.where(eq(browserUseRuns.sessionId, start.sessionId))
)[0],
).toMatchObject({ eventCursor: 9, eventsDrained: 1, accountedCents: 15 });
});
it.each(["task", "saved chat"])("protects %s browser routes from agent, other-company and read-only access", async (kind) => {
const f = await fixture();
if (kind === "saved chat") {
await db.update(issues).set({
conversationAgentId: f.agent.id,
conversationUserId: actor.actorId,
conversationState: "active",
}).where(eq(issues.id, f.issue.id));
}
await f.service.execute(
f.binding,
f.grant,
randomUUID(),
"browser_start",
{ task: "Read" },
);
f.ready();
await f.tick();
const [browser] = await f.service.list(
f.company.id,
f.issue.id,
actor.actorId,
);
let mode = "board";
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor =
mode === "agent"
? {
type: "agent",
companyId: f.company.id,
agentId: f.agent.id,
source: "agent_key",
}
: {
type: "board",
userId: actor.actorId,
source: "session",
companyIds: mode === "other-company" ? [] : [f.company.id],
};
next();
});
app.use(browserUseRoutes(db, f.service));
app.use(errorHandler);
const listPath = `/issues/${f.issue.id}/browsers`;
const listed = await http(app).get(listPath);
expect(listed.status).toBe(200);
expect(listed.body).toMatchObject([{ id: browser.id, issueId: f.issue.id }]);
const missing = await http(app).get(`/issues/${randomUUID()}/browsers`);
expect(missing.status).toBe(404);
expect(missing.body).toEqual({ error: "Task not found" });
const path = `/issues/${f.issue.id}/browsers/${browser.id}/viewer`;
const ok = await http(app).get(path);
expect(ok.status).toBe(200);
expect(ok.headers["cache-control"]).toBe("no-store");
expect(ok.headers["referrer-policy"]).toBe("no-referrer");
const controlPath = path.replace("/viewer", "/control");
const presencePath = path.replace("/viewer", "/presence");
expect((await http(app).post(presencePath)).status).toBe(200);
const resizePath = path.replace("/viewer", "/viewport");
const resizeSpy = vi
.spyOn(browserUseViewports, "resize")
.mockResolvedValue({ preset: "phone", applied: true });
expect(
(await http(app).post(resizePath).send({ preset: "phone" })).body,
).toEqual({ preset: "phone", applied: true });
expect(resizeSpy).toHaveBeenCalledTimes(1);
const viewerId = randomUUID();
resizeSpy.mockResolvedValueOnce({
preset: "fit",
width: 531,
height: 712,
applied: true,
});
expect(
(
await http(app)
.post(resizePath)
.send({ preset: "fit", width: 531, height: 712, viewerId })
).body,
).toMatchObject({ preset: "fit", width: 531 });
expect(resizeSpy).toHaveBeenLastCalledWith(
expect.any(String),
expect.any(String),
{ preset: "fit", width: 531, height: 712, viewerId },
expect.any(String),
`${actor.actorId}:${viewerId}`,
);
expect(
(await http(app).post(`${resizePath}/release`).send({ viewerId }))
.status,
).toBe(200);
expect(
(await http(app).post(resizePath).send({ preset: "custom", width: 1 }))
.status,
).toBe(400);
expect((await http(app).post(controlPath).send({ action: "cancel" })).status).toBe(200);
mode = "agent";
expect((await http(app).get(listPath)).status).toBe(403);
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(403);
expect((await http(app).post(presencePath)).status).toBe(403);
expect(
(await http(app).post(`${resizePath}/release`).send({ viewerId }))
.status,
).toBe(403);
expect(
(await http(app).post(resizePath).send({ preset: "phone" })).status,
).toBe(403);
expect((await http(app).get(path)).status).toBe(403);
mode = "other-company";
const inaccessible = await http(app).get(listPath);
expect(inaccessible.status).toBe(missing.status);
expect(inaccessible.body).toEqual(missing.body);
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(404);
expect((await http(app).post(presencePath)).status).toBe(404);
expect(
(await http(app).post(`${resizePath}/release`).send({ viewerId }))
.status,
).toBe(404);
expect(
(await http(app).post(resizePath).send({ preset: "phone" })).status,
).toBe(404);
expect((await http(app).get(path)).status).toBe(404);
mode = "board";
await db
.update(companyMemberships)
.set({ membershipRole: "viewer" })
.where(eq(companyMemberships.companyId, f.company.id));
expect((await http(app).get(path)).status).toBe(403);
expect((await http(app).get(listPath)).body).toEqual([]);
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(403);
expect((await http(app).post(presencePath)).status).toBe(403);
expect(
(await http(app).post(resizePath).send({ preset: "phone" })).status,
).toBe(403);
expect(resizeSpy).toHaveBeenCalledTimes(2);
resizeSpy.mockRestore();
});
it("rechecks current gateway access and closes browsers when it is removed", async () => {
const f = await fixture();
const start = (
await f.gateway.listToolsForSession(f.gatewaySession.token)
).find((t) => t.upstreamToolName === "browser_start")!;
await f.gateway.executeTool({
sessionToken: f.gatewaySession.token,
tool: start.name,
parameters: { task: "Read" },
idempotencyKey: randomUUID(),
});
f.ready();
await f.tick();
const [run] = await db
.select()
.from(browserUseRuns)
.where(eq(browserUseRuns.companyId, f.company.id));
const scope = {
...f.binding,
runId: run.heartbeatRunId,
connectionId: f.grant.connectionId,
grantId: f.grant.id,
invocationId: run.invocationId,
};
expect(await f.gateway.browserUseSessionAuthorized(scope)).toBe(true);
await db.insert(toolPolicies).values({
companyId: f.company.id,
name: "Disable browser access",
policyType: "block",
selectors: { connectionId: f.connection.connectionId },
priority: 100,
});
expect(await f.gateway.browserUseSessionAuthorized(scope)).toBe(false);
await db
.update(browserUseSessions)
.set({ nextPollAt: new Date(0) })
.where(eq(browserUseSessions.companyId, f.company.id));
await browserUseService(db, f.request, {}, () =>
f.gateway.browserUseSessionAuthorized(scope),
).sweep();
expect(
(
await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.id, run.sessionId))
)[0].status,
).toBe("closed");
});
it("resolves only the selected personal credential and denies another member's viewer", async () => {
const f = await fixture(true);
expect(f.grant.kind).toBe("user");
const tool = (
await f.gateway.listToolsForSession(f.gatewaySession.token)
).find((t) => t.upstreamToolName === "browser_start")!;
await f.gateway.executeTool({
sessionToken: f.gatewaySession.token,
tool: tool.name,
parameters: { task: "Read" },
idempotencyKey: randomUUID(),
});
f.ready();
await f.tick();
const [browser] = await f.service.list(
f.company.id,
f.issue.id,
actor.actorId,
);
expect(
await f.service.viewer(
f.company.id,
f.issue.id,
browser.id,
actor.actorId,
),
).toHaveProperty("url");
await db.insert(companyMemberships).values({
companyId: f.company.id,
principalType: "user",
principalId: "other-reviewer",
membershipRole: "admin",
status: "active",
});
await expect(
f.service.viewer(
f.company.id,
f.issue.id,
browser.id,
"other-reviewer",
),
).rejects.toThrow("another credential");
await f.access.archiveConnection(
f.connection.connectionId,
f.company.id,
actor,
);
expect(
(
await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.companyId, f.company.id))
)[0].status,
).toBe("closed");
});
it("continues an idle conversation and bounds Keep open with idle cleanup", async () => {
const f = await fixture();
const start = (await f.service.execute(
f.binding,
f.grant,
randomUUID(),
"browser_start",
{ task: "Read" },
)) as { sessionId: string };
f.ready();
f.complete();
await f.tick();
const [idle] = await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.id, start.sessionId));
await f.service.control(idle, "keep_open");
await f.service.execute(
f.binding,
f.grant,
randomUUID(),
"browser_continue",
{ sessionId: start.sessionId, task: "Follow up" },
);
expect(
await db
.select()
.from(browserUseRuns)
.where(eq(browserUseRuns.sessionId, start.sessionId)),
).toHaveLength(2);
const calls = f.request.mock.calls.filter(
([url, init]) => url.endsWith("/runs") && init.method === "POST",
);
expect(JSON.parse(String(calls[1][1].body)).sessionId).toBe(
idle.providerSessionId,
);
f.complete();
await f.tick();
await db
.update(browserUseSessions)
.set({ idleDeadline: new Date(0) })
.where(eq(browserUseSessions.id, start.sessionId));
await f.tick();
expect(
(
await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.id, start.sessionId))
)[0].status,
).toBe("closed");
});
it("counts browser holds with parent reservations while provider work is delayed", async () => {
const f = await fixture();
const policy = await budgetService(db).upsertPolicy(f.company.id,
{ scopeType: "company", scopeId: f.company.id, amount: 100, hardStopEnabled: true }, actor.actorId);
await db.insert(budgetReservations).values({ companyId: f.company.id, runId: f.run.id, agentId: f.agent.id, amountCents: "40" });
let release!: () => void, started!: () => void;
const providerEntered = new Promise<void>(resolve => { started = resolve; });
const providerRelease = new Promise<void>(resolve => { release = resolve; });
f.delayCreate(async () => { started(); await providerRelease; });
const first = f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Read", maxCostUsd: 0.8 });
// Attach the rejection handler before waiting for the controlled POST.
await Promise.race([providerEntered, first.then(() => undefined)]);
try {
await expect(f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Concurrent" })).rejects.toThrow("reserved");
const [row] = await db.select().from(budgetPolicies).where(eq(budgetPolicies.id, policy.policyId));
expect(await computeObservedSpend(db, row)).toMatchObject({ ledgerCents: "0.0000000", effectiveHeldCents: "100.0000000" });
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(1);
const call = f.request.mock.calls.find(([url, init]) => url.endsWith("/runs") && init.method === "POST")!;
expect(JSON.parse(String(call[1].body)).maxCostUsd).toBe(0.6);
// Accounting lock is not held while the hosted browser is waiting.
expect(await db.select({ id: companies.id }).from(companies).where(eq(companies.id, f.company.id))).toHaveLength(1);
} finally { release(); await first; }
});
it("keeps manual Stop authoritative when a paid create response arrives later", async () => {
const f = await fixture();
let release!: () => void, entered!: () => void;
const accepted = new Promise<void>(resolve => { entered = resolve; });
const barrier = new Promise<void>(resolve => { release = resolve; });
f.delayCreate(async () => { entered(); await barrier; });
const starting = f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Stop during dispatch" });
await accepted;
try {
const [session] = await db.select().from(browserUseSessions).where(eq(browserUseSessions.companyId, f.company.id));
await f.service.control(session, "end");
} finally { release(); }
await expect(starting).resolves.toMatchObject({ status: "stopping" });
await f.tick();
expect((await db.select().from(browserUseSessions).where(eq(browserUseSessions.companyId, f.company.id)))[0].status).toBe("closed");
expect(f.request.mock.calls.filter(([url]) => url.endsWith("/cancel"))).toHaveLength(1);
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toHaveLength(1);
});
it("replays simultaneous invocation retries without submitting another paid create", async () => {
const f = await fixture();
const invocation = randomUUID();
const another = browserUseService(db, f.request);
const results = await Promise.all([f.service, another].map(service =>
service.execute(f.binding, f.grant, invocation, "browser_start", { task: "One invocation" })));
expect((results[0] as { sessionId: string }).sessionId).toBe((results[1] as { sessionId: string }).sessionId);
expect(await db.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocation))).toHaveLength(1);
expect(await db.select().from(browserUseSessions).where(eq(browserUseSessions.companyId, f.company.id))).toHaveLength(1);
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(1);
});
it.each([
{ scope: "company", phase: "initial" }, { scope: "project", phase: "initial" },
{ scope: "company", phase: "handoff" }, { scope: "project", phase: "handoff" },
])("honors a $scope pause committed after the $phase admission snapshot", async ({ scope, phase }) => {
const f = await fixture();
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Browser authority" }).returning();
await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, f.issue.id));
const invocation = randomUUID();
const transaction = db.transaction.bind(db);
let injected = false;
const race = vi.spyOn(db, "transaction").mockImplementation(async (callback, config) => {
const [run] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocation));
const intercept = !injected && config?.isolationLevel === "repeatable read" && config.accessMode !== "read only"
&& (phase === "initial" ? !run : run?.status === "creating");
return transaction(async tx => {
if (intercept) {
injected = true;
// Establish the real RR snapshot before the independent operator
// transaction commits. An unfenced read would keep seeing active.
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, f.company.id));
if (scope === "company") await db.update(companies).set({ status: "paused", pauseReason: "manual", pausedAt: new Date() })
.where(eq(companies.id, f.company.id));
else await db.update(projects).set({ pauseReason: "manual", pausedAt: new Date() }).where(eq(projects.id, project.id));
}
return callback(tx);
}, config);
});
try {
await expect(f.service.execute(f.binding, f.grant, invocation, "browser_start", { task: "Stopped authority" })).rejects.toThrow();
} finally { race.mockRestore(); }
expect(injected).toBe(true);
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(0);
const rows = await db.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocation));
if (phase === "initial") expect(rows).toHaveLength(0);
else expect(rows[0]).toMatchObject({ status: "failed", eventsDrained: 1, result: { paperclipBudgetHold: { state: "released" } } });
});
it.each(["reduction", "new_policy", "config", "charge", "cancel", "session_end", "policy_deny"])("rechecks %s at the actual provider handoff", async race => {
const f = await fixture();
if (race === "reduction" || race === "charge") await budgetService(db).upsertPolicy(f.company.id,
{ scopeType: "company", scopeId: f.company.id, amount: 100, hardStopEnabled: true }, actor.actorId);
const invocationId = randomUUID();
const transaction = db.transaction.bind(db);
let injected = false;
const barrier = vi.spyOn(db, "transaction").mockImplementation(async (callback, config) => {
const result = await transaction(callback, config);
const [run] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocationId));
if (!injected && run?.status === "creating" && !(run.result as { paperclipBudgetHold?: { dispatchCommittedAt?: string } })?.paperclipBudgetHold?.dispatchCommittedAt) {
injected = true;
if (race === "reduction" || race === "new_policy") await budgetService(db).upsertPolicy(f.company.id,
{ scopeType: "company", scopeId: f.company.id, amount: race === "new_policy" ? 100 : 10, hardStopEnabled: true }, actor.actorId);
else if (race === "config") await f.service.saveSettings(f.company.id, f.grant.id, actor.actorId, { allowedProfileIds: [], maxCostUsd: 0.1 });
else if (race === "charge") {
const { costService } = await import("../services/costs.js");
await costService(db).createEvent(f.company.id, { agentId: f.agent.id, provider: "fixture", model: "fixture", costCents: 20, occurredAt: new Date() });
} else if (race === "policy_deny") await db.insert(toolPolicies).values({ companyId: f.company.id,
name: "Deny before browser handoff", policyType: "block", selectors: { connectionId: f.connection.connectionId }, priority: 0 });
else if (race === "session_end") await db.update(browserUseSessions).set({ status: "stopping", stopRequested: "end" }).where(eq(browserUseSessions.id, run.sessionId));
else await db.update(heartbeatRuns).set({ resultJson: { startupCancellation: { requestedAt: new Date().toISOString() } } }).where(eq(heartbeatRuns.id, f.run.id));
}
return result;
});
try {
await expect(f.service.execute(f.binding, f.grant, invocationId, "browser_start", { task: "Must not start" })).rejects.toThrow();
} finally { barrier.mockRestore(); }
expect(injected).toBe(true);
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(0);
const [run] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocationId));
expect(run).toMatchObject({ status: "failed", eventsDrained: 1, result: { paperclipBudgetHold: { state: "released" } } });
});
it.each(["default_allow", "tool_name"])("retains valid %s profile authorization at browser admission", async selector => {
const f = await fixture();
await db.update(toolProfiles).set({ status: "disabled" }).where(eq(toolProfiles.companyId, f.company.id));
const [profile] = await db.insert(toolProfiles).values({ companyId: f.company.id, name: "Scoped browser access",
profileKey: randomUUID(), defaultAction: selector === "default_allow" ? "allow" : "deny" }).returning();
await db.insert(toolProfileBindings).values({ companyId: f.company.id, profileId: profile.id, targetType: "agent", targetId: f.agent.id });
if (selector === "tool_name") await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: profile.id,
selectorType: "tool_name", toolName: "browser_start", effect: "include" });
await expect(f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Allowed by profile" })).resolves.toMatchObject({ status: "running" });
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(1);
});
it("checks the gateway invocation's namespaced tool identity for explicit denial", async () => {
const f = await fixture();
const invocationId = randomUUID(), toolName = "app.browser-fixture:browser-start";
const [entry] = await db.select().from(toolCatalogEntries).where(and(eq(toolCatalogEntries.connectionId, f.connection.connectionId), eq(toolCatalogEntries.toolName, "browser_start")));
await db.insert(toolInvocations).values({ id: invocationId, companyId: f.company.id, agentId: f.agent.id,
runId: f.run.id, issueId: f.issue.id, actorType: "agent", actorId: f.agent.id,
connectionId: f.connection.connectionId, applicationId: entry.applicationId, catalogEntryId: entry.id,
toolName, upstreamToolName: "browser_start", policyDecision: "allow", status: "executing" });
await db.insert(toolPolicies).values({ companyId: f.company.id, name: "Block namespaced invocation",
policyType: "block", selectors: { toolName }, priority: 0 });
await expect(f.service.execute(f.binding, f.grant, invocationId, "browser_start", { task: "Denied" })).rejects.toThrow("blocked");
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(0);
});
it("releases a crashed reservation that never reached provider dispatch authority", async () => {
const f = await fixture();
const policy = await budgetService(db).upsertPolicy(f.company.id,
{ scopeType: "agent", scopeId: f.agent.id, amount: 100, hardStopEnabled: true }, actor.actorId);
const [session] = await db.insert(browserUseSessions).values({ companyId: f.company.id, issueId: f.issue.id,
agentId: f.agent.id, connectionId: f.connection.connectionId, grantId: f.grant.id, status: "starting" }).returning();
const invocationId = randomUUID();
const [run] = await db.insert(browserUseRuns).values({ companyId: f.company.id, sessionId: session.id,
heartbeatRunId: f.run.id, invocationId, createdAt: new Date(Date.now() - 61_000),
result: { paperclipBudgetHold: { invocationId, amountCents: "50.0000000", state: "held", projectId: null } } }).returning();
f.request.mockClear();
await f.tick();
const [policyRow] = await db.select().from(budgetPolicies).where(eq(budgetPolicies.id, policy.policyId));
expect((await computeObservedSpend(db, policyRow)).effectiveHeldCents).toBe("0.0000000");
expect((await db.select().from(browserUseRuns).where(eq(browserUseRuns.id, run.id)))[0]).toMatchObject({ status: "failed", eventsDrained: 1, result: { paperclipBudgetHold: { state: "released" } } });
expect((await db.select().from(browserUseSessions).where(eq(browserUseSessions.id, session.id)))[0].status).toBe("failed");
expect(f.request.mock.calls.some(([url]) => new URL(url).pathname === "/api/v4/runs")).toBe(false);
});
it("releases a definitively rejected browser hold without inventing a charge", async () => {
const f = await fixture();
const policy = await budgetService(db).upsertPolicy(f.company.id,
{ scopeType: "agent", scopeId: f.agent.id, amount: 100, hardStopEnabled: true }, actor.actorId);
f.reject(409);
await expect(f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Rejected", maxCostUsd: 0.5 })).rejects.toThrow("busy");
const [row] = await db.select().from(budgetPolicies).where(eq(budgetPolicies.id, policy.policyId));
expect(await computeObservedSpend(db, row)).toMatchObject({ ledgerCents: "0.0000000", effectiveHeldCents: "0.0000000" });
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toHaveLength(0);
await db.update(browserUseRuns).set({ result: null }).where(eq(browserUseRuns.companyId, f.company.id));
expect((await computeObservedSpend(db, row)).unknownBrowserRunCount).toBe(0);
});
it.each(["company", "agent", "project"] as const)("treats a running uncapped browser as unknown when a %s budget is introduced", async scopeType => {
for (const legacy of [false, true]) {
const f = await fixture();
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Browser project" }).returning();
const [otherProject] = await db.insert(projects).values({ companyId: f.company.id, name: "Unrelated project" }).returning();
await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, f.issue.id));
const invocation = randomUUID();
await expect(f.service.execute(f.binding, f.grant, invocation, "browser_start", { task: "Uncapped running work" })).resolves.toMatchObject({ status: "running" });
const [run] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocation));
expect(run.result).toMatchObject({ paperclipBudgetHold: { amountCents: "0.0000000", uncapped: true } });
expect(JSON.parse(String(f.request.mock.calls.find(([url, init]) => url.endsWith("/runs") && init.method === "POST")![1].body))).not.toHaveProperty("maxCostUsd");
if (legacy) await db.update(browserUseRuns).set({ result: null }).where(eq(browserUseRuns.id, run.id));
const scopeId = scopeType === "company" ? f.company.id : scopeType === "agent" ? f.agent.id : project.id;
const policy = await budgetService(db).upsertPolicy(f.company.id,
{ scopeType, scopeId, amount: 100, hardStopEnabled: true }, actor.actorId);
const [row] = await db.select().from(budgetPolicies).where(eq(budgetPolicies.id, policy.policyId));
expect(await computeObservedSpend(db, row)).toMatchObject({ ledgerCents: "0.0000000", effectiveHeldCents: "0.0000000", unknownBrowserRunCount: 1 });
expect((await computeObservedSpend(db, { ...row, scopeType: "project", scopeId: otherProject.id })).unknownBrowserRunCount).toBe(0);
expect(await budgetService(db).getInvocationBlock(f.company.id, f.agent.id, { projectId: project.id })).not.toBeNull();
f.request.mockClear();
await expect(f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Must wait for priced work" })).rejects.toThrow();
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(0);
await budgetService(db).upsertPolicy(f.company.id,
{ scopeType, scopeId, amount: 100, hardStopEnabled: true, unpricedUsagePolicy: "allow" }, actor.actorId);
expect(await budgetService(db).getInvocationBlock(f.company.id, f.agent.id, { projectId: project.id })).toBeNull();
f.complete(); await f.tick();
expect(await computeObservedSpend(db, row)).toMatchObject({ ledgerCents: "15.0000000", effectiveHeldCents: "0.0000000", unknownBrowserRunCount: 0 });
}
});
it.each([
{ label: "explicit capped zero", uncapped: false, state: "held", dispatched: true, status: "running", expected: 0 },
{ label: "legacy zero cap without a flag", uncapped: undefined, state: "held", dispatched: true, status: "running", expected: 1 },
{ label: "uncapped before dispatch", uncapped: true, state: "held", dispatched: false, status: "creating", expected: 0 },
{ label: "failed before dispatch", uncapped: true, state: "released", dispatched: false, status: "failed", expected: 0 },
])("distinguishes $label from unfinished uncapped spending", async item => {
const f = await fixture();
const [session] = await db.insert(browserUseSessions).values({ companyId: f.company.id, issueId: f.issue.id,
agentId: f.agent.id, connectionId: f.connection.connectionId, grantId: f.grant.id, status: "running" }).returning();
const invocationId = randomUUID();
await db.insert(browserUseRuns).values({ companyId: f.company.id, sessionId: session.id, heartbeatRunId: f.run.id,
invocationId, status: item.status, providerRunId: item.dispatched ? randomUUID() : null, eventsDrained: item.status === "failed" ? 1 : 0,
result: { paperclipBudgetHold: { invocationId, amountCents: "0.0000000", projectId: null, state: item.state,
...(item.uncapped === undefined ? {} : { uncapped: item.uncapped }),
...(item.dispatched ? { dispatchCommittedAt: new Date().toISOString() } : {}) } } });
const policy = await budgetService(db).upsertPolicy(f.company.id,
{ scopeType: "company", scopeId: f.company.id, amount: 100, hardStopEnabled: true }, actor.actorId);
const [row] = await db.select().from(budgetPolicies).where(eq(budgetPolicies.id, policy.policyId));
expect(await computeObservedSpend(db, row)).toMatchObject({ ledgerCents: "0.0000000", effectiveHeldCents: "0.0000000", unknownBrowserRunCount: item.expected });
const block = await budgetService(db).getInvocationBlock(f.company.id, f.agent.id);
expect(Boolean(block)).toBe(item.expected > 0);
});
it("blocks new budgeted work after an uncapped browser POST becomes uncertain", async () => {
const f = await fixture();
f.loseReply();
const uncertain = randomUUID();
await expect(f.service.execute(f.binding, f.grant, uncertain, "browser_start", { task: "Uncapped" })).rejects.toThrow("could not be confirmed");
const policy = await budgetService(db).upsertPolicy(f.company.id,
{ scopeType: "company", scopeId: f.company.id, amount: 100, hardStopEnabled: true }, actor.actorId);
const [row] = await db.select().from(budgetPolicies).where(eq(budgetPolicies.id, policy.policyId));
expect(await computeObservedSpend(db, row)).toMatchObject({ ledgerCents: "0.0000000", effectiveHeldCents: "0.0000000", unknownBrowserRunCount: 1 });
f.request.mockClear();
await expect(f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Must wait" })).rejects.toThrow();
expect(f.request.mock.calls.filter(([url, init]) => url.endsWith("/runs") && init.method === "POST")).toHaveLength(0);
// The existing unpriced-usage policy remains the explicit operator override.
await budgetService(db).upsertPolicy(f.company.id,
{ scopeType: "company", scopeId: f.company.id, amount: 100, hardStopEnabled: true, unpricedUsagePolicy: "allow" }, actor.actorId);
expect(await budgetService(db).getInvocationBlock(f.company.id, f.agent.id)).toBeNull();
f.usage("0.15");
await f.tick();
expect(await computeObservedSpend(db, row)).toMatchObject({ ledgerCents: "15.0000000", unknownBrowserRunCount: 0 });
});
it("retains unknown browser holds and settles exact terminal usage without exposing internal metadata", async () => {
const f = await fixture();
const policy = await budgetService(db).upsertPolicy(f.company.id,
{ scopeType: "company", scopeId: f.company.id, amount: 100, hardStopEnabled: true }, actor.actorId);
f.loseReply();
const invocation = randomUUID();
await expect(f.service.execute(f.binding, f.grant, invocation, "browser_start", { task: "Read", maxCostUsd: 0.5 })).rejects.toThrow("could not be confirmed");
const [row] = await db.select().from(budgetPolicies).where(eq(budgetPolicies.id, policy.policyId));
expect((await computeObservedSpend(db, row)).effectiveHeldCents).toBe("50.0000000");
f.usage("0.150000001");
await f.tick();
expect(await computeObservedSpend(db, row)).toMatchObject({ ledgerCents: "15.0000001", effectiveHeldCents: "0.0000000" });
const [run] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocation));
expect(run.result).toMatchObject({ paperclipBudgetHold: { state: "settled", accountedCentsExact: "15.0000001" } });
const replay = await f.service.execute(f.binding, f.grant, invocation, "browser_start", { task: "Read" });
expect(JSON.stringify(replay)).not.toContain("paperclipBudgetHold");
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toHaveLength(1);
});
it("settles while company, agent and policy rows are held by unrelated writers", async () => {
const f = await fixture();
const policy = await budgetService(db).upsertPolicy(f.company.id,
{ scopeType: "company", scopeId: f.company.id, amount: 100, hardStopEnabled: true }, actor.actorId);
await f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", { task: "Independent settlement" });
f.complete();
await db.update(browserUseSessions).set({ nextPollAt: new Date(0) }).where(eq(browserUseSessions.companyId, f.company.id));
let release!: () => void, locked!: () => void;
const held = new Promise<void>(resolve => { locked = resolve; });
const barrier = new Promise<void>(resolve => { release = resolve; });
const blocker = db.transaction(async tx => {
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, f.company.id)).for("no key update");
await tx.select({ id: agents.id }).from(agents).where(eq(agents.id, f.agent.id)).for("no key update");
await tx.select({ id: budgetPolicies.id }).from(budgetPolicies).where(eq(budgetPolicies.id, policy.policyId)).for("no key update");
locked();
await barrier;
});
await held;
let timeout: ReturnType<typeof setTimeout> | undefined;
const settlement = f.service.sweep(f.connection.connectionId);
try {
await Promise.race([settlement, new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error("Browser settlement waited for a shared accounting row")), 3000);
})]);
const [event] = await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id));
expect(event).toMatchObject({ provider: "browser-use-cloud", costCents: 15 });
const [run] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.companyId, f.company.id));
expect(run).toMatchObject({ eventsDrained: 1, result: { paperclipBudgetHold: { state: "settled" } } });
} finally {
if (timeout) clearTimeout(timeout);
release();
await blocker;
await settlement;
}
});
it("replays a captured terminal receipt after ledger failure without refetching or moving its attribution", async () => {
const f = await fixture();
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Original browser project" }).returning();
const [laterProject] = await db.insert(projects).values({ companyId: f.company.id, name: "Later task project" }).returning();
await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, f.issue.id));
const invocation = randomUUID();
await f.service.execute(f.binding, f.grant, invocation, "browser_start", { task: "Capture once" });
f.usage("0.150000001"); f.complete();
const failedLedger = vi.spyOn(costAccounting, "createCostEventInTransaction").mockRejectedValueOnce(new Error("Accounting unavailable"));
try { await f.tick(); } finally { failedLedger.mockRestore(); }
const [pending] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocation));
const receipt = (pending.result as { paperclipBudgetHold: { receipt: { occurredAt: string } } }).paperclipBudgetHold.receipt;
expect(pending.eventsDrained).toBe(0);
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toHaveLength(0);
await db.update(issues).set({ projectId: laterProject.id }).where(eq(issues.id, f.issue.id));
f.usage("0.99"); f.request.mockClear();
await f.tick();
const [event] = await db.select({ projectId: costEvents.projectId, occurredAt: costEvents.occurredAt,
cents: sql<string>`${costEvents.costCents}::text` }).from(costEvents).where(eq(costEvents.companyId, f.company.id));
expect(event).toMatchObject({ projectId: project.id, cents: "15.0000001" });
expect(event.occurredAt.toISOString()).toBe(receipt.occurredAt);
expect(f.request.mock.calls.some(([url]) => /\/runs\/[^/]+$/.test(new URL(url).pathname))).toBe(false);
expect(await db.select().from(financeEvents).where(eq(financeEvents.companyId, f.company.id))).toHaveLength(1);
});
it("retains exact cumulative accounting when an older browser row has no hold projection", async () => {
const f = await fixture();
const invocation = randomUUID();
await f.service.execute(f.binding, f.grant, invocation, "browser_start", { task: "Legacy result" });
const [run] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocation));
await db.update(browserUseRuns).set({ result: null }).where(eq(browserUseRuns.id, run.id));
f.usage("0.150000001"); f.complete(); await f.tick();
await db.update(browserUseRuns).set({ eventsDrained: 0 }).where(eq(browserUseRuns.id, run.id));
f.usage("0.150000002"); await f.tick();
const [total] = await db.select({ cents: sql<string>`sum(${costEvents.costCents})::text` }).from(costEvents).where(eq(costEvents.companyId, f.company.id));
expect(total.cents).toBe("15.0000002");
expect((await db.select().from(browserUseRuns).where(eq(browserUseRuns.id, run.id)))[0].result)
.toMatchObject({ paperclipBudgetHold: { state: "settled", accountedCentsExact: "15.0000002" } });
await db.update(browserUseRuns).set({ eventsDrained: 0 }).where(eq(browserUseRuns.id, run.id));
f.usage("0.10"); await f.tick();
expect(await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id))).toHaveLength(2);
expect((await db.select().from(browserUseRuns).where(eq(browserUseRuns.id, run.id)))[0].result)
.toMatchObject({ paperclipBudgetHold: { state: "settled", accountedCentsExact: "15.0000002" } });
});
it("dates a new cumulative delta in its capture month and keeps that receipt's retry time stable", async () => {
const f = await fixture();
const before = new Date("2026-11-30T23:59:59.900Z"), after = new Date("2026-12-01T00:00:00.100Z");
vi.useFakeTimers({ toFake: ["Date"] });
try {
vi.setSystemTime(before);
const invocation = randomUUID();
await f.service.execute(f.binding, f.grant, invocation, "browser_start", { task: "Cumulative usage across UTC rollover" });
f.usage("0.15"); f.complete(); await f.tick();
const [run] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.invocationId, invocation));
await db.update(browserUseRuns).set({ eventsDrained: 0 }).where(eq(browserUseRuns.id, run.id));
vi.setSystemTime(after);
f.usage("0.20");
const failed = vi.spyOn(costAccounting, "createCostEventInTransaction").mockRejectedValueOnce(new Error("Retry this receipt"));
try { await f.tick(); } finally { failed.mockRestore(); }
const [captured] = await db.select().from(browserUseRuns).where(eq(browserUseRuns.id, run.id));
expect(captured).toMatchObject({ eventsDrained: 0, result: { paperclipBudgetHold: {
accountedCentsExact: "15.0000000", receipt: { totalCentsExact: "20.0000000", occurredAt: after.toISOString() },
} } });
vi.setSystemTime(new Date("2026-12-01T00:00:05.100Z"));
f.usage("0.99");
await f.tick(); await f.tick();
const events = await db.select({ cents: sql<string>`${costEvents.costCents}::text`, occurredAt: costEvents.occurredAt })
.from(costEvents).where(eq(costEvents.companyId, f.company.id)).orderBy(costEvents.occurredAt);
expect(events).toEqual([{ cents: "15.0000000", occurredAt: before }, { cents: "5.0000000", occurredAt: after }]);
expect(await db.select().from(financeEvents).where(eq(financeEvents.companyId, f.company.id))).toHaveLength(2);
} finally { vi.useRealTimers(); }
});
it("caps paid work at the remaining hard budget and enforces reported spend", async () => {
const f = await fixture();
await budgetService(db).upsertPolicy(
f.company.id,
{
scopeType: "agent",
scopeId: f.agent.id,
amount: 15,
hardStopEnabled: true,
},
actor.actorId,
);
await f.service.saveSettings(f.company.id, f.grant.id, actor.actorId, {
allowedProfileIds: [],
maxCostUsd: 0.5,
});
await f.service.execute(
f.binding,
f.grant,
randomUUID(),
"browser_start",
{ task: "Read", maxCostUsd: 1 },
);
const call = f.request.mock.calls.find(
([url, init]) => url.endsWith("/runs") && init.method === "POST",
)!;
expect(JSON.parse(String(call[1].body))).toMatchObject({
maxCostUsd: 0.15,
browserSettings: { record: false },
});
f.ready();
f.complete();
await f.tick();
await f.tick();
expect(
(await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0],
).toMatchObject({ status: "paused" });
const [charged] = await db.select({ cents: sql<string>`sum(${costEvents.costCents})::text` }).from(costEvents)
.where(eq(costEvents.companyId, f.company.id));
expect(charged.cents).toBe("15.0000000");
expect(
(
await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.companyId, f.company.id))
)[0].status,
).toBe("closed");
await expect(
f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", {
task: "Read again",
}),
).rejects.toThrow();
});
it("preserves the idle grace period when the task and parent finish before the last poll", async () => {
const f = await fixture();
await f.service.execute(
f.binding,
f.grant,
randomUUID(),
"browser_start",
{ task: "Read" },
);
f.ready();
f.complete();
await db
.update(heartbeatRuns)
.set({ status: "succeeded" })
.where(eq(heartbeatRuns.id, f.run.id));
await db
.update(issues)
.set({ status: "done" })
.where(eq(issues.id, f.issue.id));
await f.tick();
expect(
(
await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.companyId, f.company.id))
)[0].status,
).toBe("idle");
expect(
f.request.mock.calls.some(([url]) => url.endsWith("/cancel")),
).toBe(false);
});
it("confirms hosted shutdown before removing the connection credential", async () => {
const f = await fixture();
await f.service.execute(
f.binding,
f.grant,
randomUUID(),
"browser_start",
{ task: "Read" },
);
f.ready();
await f.tick();
await f.access.archiveConnection(
f.connection.connectionId,
f.company.id,
actor,
);
const [session] = await db
.select()
.from(browserUseSessions)
.where(eq(browserUseSessions.companyId, f.company.id));
expect(session.status).toBe("closed");
const [grant] = await db
.select()
.from(connectionGrants)
.where(eq(connectionGrants.id, f.grant.id));
expect(grant.status).toBe("revoked");
expect(grant.credentialSecretRefs).toEqual([]);
});
},
);