Files
PaperClipAI/server/src/__tests__/browser-use-connection.test.ts
T
DottaandPaperclip d72389bee2 feat: add Browser Use Cloud connector and live task browsers (#14627)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Apps gateway gives agents governed access to external tools.
> - Browser Use Cloud can run browser work, but a tool result alone does
not let a person watch or take over.
> - A task needs a durable browser session, a visible viewer, and
recorded costs.
> - This pull request adds a Browser Use Cloud v4 connection and
interactive browser tabs on tasks.
> - People can follow the work, interact with the page, and retain the
browser after the agent finishes.

## Linked Issues or Issue Description

**Problem or motivation**

Agents need governed access to Browser Use Cloud. People need to see and
interact with the same browser from the task. A browser must remain
available after a run finishes and appear at the correct point in the
task feed.

**Proposed solution**

Add a native REST connection for the v4 API. Bind each session to its
company, task, agent, and credential grant. Open its interactive viewer
in the task side panel. Record provider costs as financial events. Use
`browser-use-cloud` as the app and connector key. Keep its skill with
the connector and deliver it only with authorized connection tools.

**Alternatives considered**

A v3 MCP connection would expose tools without the v4 lifecycle
integration. An external viewer link would leave the task. A fixed
viewer size would prevent pages from responding to changes in the task
pane.

**Roadmap alignment**

This extends the governed Apps gateway and Connected Apps roadmap. It
uses the existing task, grant, secret, approval, and financial records.
The work was requested by the maintainer. A search found no duplicate
Browser Use connector PR or issue.

## What Changed

- Add the Browser Use Cloud app, brand asset, API-key connection, and
profile settings under the `browser-use-cloud` key.
- Bundle the `browser-use-cloud` skill with the connector. Keep it out
of global `skills/` discovery. Deliver it only with authorized task/run
connection tools. Remove retired connector skill keys from runtime
overlays and preserve unrelated browser skills.
- Expose seven v4 tools through the governed gateway and deliver them to
native and CLI agents.
- Persist sessions, browsers, runs, event and recovery cursors, shutdown
leases, and cumulative cost accounting. Recover uncertain paid starts
without replaying them.
- Enforce task ownership, credential grants, approvals, revoked access,
and budget limits.
- Add interactive task browser tabs and compact chronological feed
entries. Retain the viewer across tab switches and keep visible idle
browsers open.
- Add debounced automatic viewport fitting, standard size presets, and a
viewer ownership lease.
- Add lifecycle, authorization, accounting, viewport, UI, and Storybook
coverage.
- Add an idempotent database migration after the current master
migration. Preserve deployed migration hashes. Migrate pre-release Cloud
connection and financial keys without replacing grants, credentials, or
browser history.
- Document provider behavior, live acceptance results, and the lack of
documented passkey forwarding.

## Verification

- Full workspace typecheck and production build pass on the updated
branch.
- Token gates, brand asset validation, module boundaries, and migration
ordering pass.
- Cloud tests verify global skill exclusion, authorized task/run
delivery, unassigned agents, disabled connections, revocation, adapter
isolation, and secret exclusion. The existing AgentMail connector
assignment test also passes.
- Migration replay runs twice against existing browser work and
financial records. It preserves the records and avoids duplicate costs.
- The focused provider, app catalog, OpenAPI, connection gateway, and
migration regression suites pass. Recovery coverage includes lost
replies, process crashes, provider rejection, and browser arrival
acknowledgement.
- All 54 checks pass on `2974b5f03641ad0cea3c941d8c02579316fa8c92`,
including the full test matrix, browser E2E shards, build, typecheck,
security, and release canary. Two optional Storybook jobs are skipped.
- Greptile is 5/5 on the same commit, with zero unresolved review
threads. The corrected review uses the actual master-to-head diff.
- The local `pnpm test:run` started and was stopped after the full CI
matrix passed. It did not complete locally; the full-suite result above
comes from CI.
- Earlier live acceptance used an isolated company with a capped
provider credential. The agent opened paperclip.ing, the embedded viewer
accepted navigation, and the same browser stayed available after
completion and tab switches.
- The local Storybook build passes. Stories cover the panel, footer,
feed entries, settings, lifecycle failures, and viewport modes with an
offline viewer fixture.

## Risks

- Browser Use charges for hosted work. Provider caps and local budget
checks reduce exposure; reported costs can arrive after work completes.
- Viewer and CDP URLs grant access to the browser. The server validates
and restricts them. They are excluded from agent results and durable
event data.
- Runtime resizing of v4 agent browsers uses a provider option confirmed
by live testing but absent from its published agent schema. Resizing
during a click may invalidate coordinates. Fixed presets remain
available.
- Viewport ownership is process-local and resets on restart. The
lifecycle and accounting records remain in the database.
- The original intermittent embedded-viewer stall has not been fully
diagnosed. A bounded reconnect and active-session recovery cover the
observed failure paths.
- Live tests did not cover every revocation, approval, rate-limit, or
restart case. Deterministic integration tests cover those paths. Passkey
forwarding is not claimed.
- Unknown create outcomes keep the credential available for cleanup.
Run-list absence cannot prove a paid POST was rejected, so recovery
stays pending until it can identify provider work.

## Model Used

OpenAI Codex, GPT-6. Used reasoning, repository search, code execution,
browser interaction, and test tools. The exact serving model ID and
context-window size were 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-09-30 07:13:16 -05:00

1372 lines
52 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 { 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("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("protects interactive viewer routes from agent, other-company and read-only access", async () => {
const f = await fixture();
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 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 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);
mode = "agent";
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";
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).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([]);
});
},
);