Files
PaperClipAI/server/src/__tests__/browser-use-connection.test.ts
T
DottaandPaperclip 35511366dc perf(server): schedule browser cleanup only while sessions exist (#15757)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Hosted instances must become quiet when they have no work.
> - Browser Use queried its session table every three seconds, even when
empty.
> - Existing session rows already store the next poll time and cleanup
lease.
> - This pull request uses commit signals and those saved deadlines to
schedule cleanup.
> - Empty instances stop browser database scans. Live sessions keep
their safety checks.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Browser Use background cleanup and recovery. This follows #15625 and
#15750.

**Subsystem affected**

server/ — REST API and orchestration services.

**Current behavior**

The app scans for due sessions every three seconds, including instances
that have never used Browser Use.

**Proposed behavior**

Scan once at startup. Wake after session creation, provider-create
settlement, stop requests, or credential-removal cleanup. Schedule the
next check from `nextPollAt` and `leaseUntil`. Stop scheduling when all
sessions are terminal.

**Reason and benefit**

Remove empty database traffic so idle stacks and their database
connections can become quiet.

**Breaking changes**

No API or schema change. Running and idle sessions retain provider
polling, cost accounting, access checks, expiry checks, and cleanup
retries.

## What Changed

- Register browser cleanup with the existing work coordinator. Remove
the app's three-second interval.
- Signal existing session transactions before writes. No new tables or
provider infrastructure.
- Restore deadlines from live sessions and their leases. Wake after a
credential-removal sweep releases its lease.
- Wait for concurrent cleanup instead of returning while it still runs.
Drain and standby preserve deferred work.
- Add scheduler tests and database-backed commit and restart coverage.
Update the connector guide and sleep inventory.

## Verification

- Passed: focused browser worker and shared coordinator tests, 17 tests.
- Passed: browser lifecycle, route-scope, viewport, and transport tests,
50 tests. The lifecycle suite uses real local Postgres and a simulated
provider.
- Passed: `pnpm -r typecheck` and `pnpm build`.
- Passed: all hosted test groups for this commit. Started full local
`pnpm test:run`, then stopped its duplicate serial run after the
complete hosted sharded suite passed; no full local-suite pass is
claimed.
- Passed: direct server TypeScript check. Greptile reviewed
`99c3217a2dea505be94081e729941e542e6fbc03` at 5/5 with no findings. All
54 applicable hosted checks passed; the two Storybook checks were
skipped because this PR has no UI changes.

## Risks

- Process-local signals cover services that share the instrumented
database owner. Independent processes and direct SQL writes require
startup recovery; this does not add cross-process notification.
- Live sessions still poll. This removes empty scans; it does not make a
running hosted browser sleep.
- Cleanup uses the existing provider protocol and lease fences. Tests
simulate the provider; no paid live browser was created for this
scheduling change.

## Model Used

OpenAI Codex, GPT-6, with reasoning, tool use, and code execution. The
exact deployment model ID and context window are not exposed in this
session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-10 07:06:31 -05:00

1439 lines
56 KiB
TypeScript

import { budgetService } from "../services/budgets.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,
toolPolicies,
companies,
companyMemberships,
connectionGrants,
createDb,
heartbeatRuns,
issues,
toolConnections,
toolApplications,
toolConnectionInstalls,
toolProfiles,
toolProfileBindings,
browserUseSessions,
browserUseRuns,
browserUseBrowsers,
costEvents,
financeEvents,
} 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 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") {
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: "0.15",
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();
};
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; },
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("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" });
expect(
(await http(app).post(resizePath).send({ preset: "phone" })).body,
).toEqual({ preset: "phone" });
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("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", spentMonthlyCents: 15 });
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([]);
});
},
);