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

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

1672 lines
69 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, desc, eq, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
import { z } from "zod";
import {
type Db,
browserUseSessions as sessions,
browserUseRuns as runs,
browserUseBrowsers as browsers,
browserUseSettings as settings,
connectionGrants,
connectionGrantMembers,
companyMemberships,
companySecrets,
toolInvocations,
toolConnections,
issues,
heartbeatRuns,
agents,
companies,
toolCatalogEntries,
} from "@paperclipai/db";
import {
BROWSER_USE_IDLE_MS,
type BrowserUseControl,
type BrowserUseSettings,
type BrowserUseViewportRequest,
type TaskBrowser,
centsToUnits,
unitsToCents,
centsToUsd,
usdToUnits,
} from "@paperclipai/shared";
import {
browserUseClient,
BrowserUseError,
browserUseTerminal,
browserUseViewerUrl,
isBrowserUseConnection,
sanitizeBrowserUse,
type BrowserUseRequest,
} from "./browser-use-client.js";
import { secretService } from "./secrets.js";
import { budgetService, budgetServiceInTransaction, type BudgetServiceHooks } from "./budgets.js";
import { issueTreeControlService } from "./issue-tree-control.js";
import { accessService } from "./access.js";
import { toolAccessPolicyService } from "./tool-access-policy.js";
import { createCostEventInTransaction } from "./costs.js";
import { createFinanceEventInTransaction } from "./finance.js";
import { withOperationAccountingTransaction } from "./accounting-transaction.js";
import { logActivity, type ActivityPublication } from "./activity-log.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { browserUseViewports } from "./browser-use-viewport.js";
import { withBudgetSpan } from "./budget-tracing.js";
import { forbidden, notFound, conflict } from "../errors.js";
import { isUniqueViolation } from "../db-errors.js";
import { lockBudgetAdmissionControls } from "./budget-admission-controls.js";
type Session = typeof sessions.$inferSelect;
type Binding = {
companyId: string;
agentId: string | null;
runId: string | null;
issueId: string | null;
approvedInvocationId?: string;
gatewayId?: string | null;
};
type Grant = typeof connectionGrants.$inferSelect;
const argsSchema = z
.object({
task: z.string().min(1).max(30000).optional(),
sessionId: z.string().uuid().optional(),
profileId: z.string().uuid().optional(),
maxCostUsd: z.number().positive().finite().optional(),
})
.strict();
const terminalSessions = ["closed", "failed"];
const liveSessions = ["starting", "running", "idle", "stopping"];
const requestMarker = (invocationId: string) => `\n\n[Paperclip request: ${invocationId}]`;
type BrowserBudgetHold = { invocationId: string; amountCents: string; projectId: string | null;
state: "held" | "released" | "settled"; uncapped?: boolean;
dispatchCommittedAt?: string; accountedCentsExact?: string; billingUnknown?: boolean;
receipt?: BrowserUsageReceipt };
type BrowserUsageReceipt = { invocationId: string; providerRunId: string; totalCentsExact: string;
model: string; occurredAt: string };
const resultObject = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const exactNonnegativeCents = z.string().refine(value => {
try { return centsToUnits(value) >= 0n; } catch { return false; }
});
const browserReceiptSchema = z.object({ invocationId: z.string(), providerRunId: z.string(),
totalCentsExact: exactNonnegativeCents, model: z.string(),
occurredAt: z.string().refine(value => Number.isFinite(new Date(value).getTime())) });
const browserHoldSchema = z.object({ invocationId: z.string(), amountCents: exactNonnegativeCents.default("0.0000000"),
projectId: z.string().nullable().default(null), state: z.enum(["held", "released", "settled"]),
uncapped: z.boolean().optional(), dispatchCommittedAt: z.string().optional(), accountedCentsExact: exactNonnegativeCents.optional(),
billingUnknown: z.boolean().optional(), receipt: browserReceiptSchema.optional() });
function budgetHold(value: unknown): BrowserBudgetHold | undefined {
const raw = resultObject(value).paperclipBudgetHold;
if (raw === undefined) return undefined;
const parsed = browserHoldSchema.safeParse(raw);
if (!parsed.success) throw conflict("Browser accounting evidence is invalid.");
return parsed.data;
}
function publicResult(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const { paperclipBudgetHold: _internal, ...visible } = resultObject(value);
return Object.keys(visible).length ? visible : null;
}
export function browserUseService(
db: Db,
request?: BrowserUseRequest,
budgetHooks: BudgetServiceHooks = {},
authorizeSession?: (
session: Session,
run: typeof runs.$inferSelect,
) => Promise<boolean>,
) {
const budgets = budgetService(db, budgetHooks);
const secrets = secretService(db);
async function credentials(
s: Pick<Session, "companyId" | "connectionId" | "grantId">,
cleanup = false,
) {
const [row] = await db
.select({ grant: connectionGrants, connection: toolConnections })
.from(connectionGrants)
.innerJoin(
toolConnections,
eq(toolConnections.id, connectionGrants.connectionId),
)
.where(
and(
eq(connectionGrants.id, s.grantId),
eq(connectionGrants.companyId, s.companyId),
eq(toolConnections.companyId, s.companyId),
eq(toolConnections.id, s.connectionId),
),
)
.limit(1);
if (!row || !isBrowserUseConnection(row.connection))
throw forbidden("Browser connection is unavailable.");
if (
!cleanup &&
(row.grant.status !== "active" ||
row.connection.status !== "active" ||
!row.connection.enabled)
)
throw forbidden("Browser access was revoked.");
const ref = row.grant.credentialSecretRefs.find(
(r) =>
r.configPath === "credentials.apiKey" ||
r.configPath.toLowerCase() === "headers.x-browser-use-api-key",
);
if (!ref) throw forbidden("Reconnect Browser Use to restore its API key.");
const [credentialSnapshot] = await db.select().from(companySecrets).where(and(
eq(companySecrets.companyId, s.companyId), eq(companySecrets.id, ref.secretId)));
if (!credentialSnapshot || (!cleanup && credentialSnapshot.status !== "active"))
throw forbidden("Browser credential is unavailable.");
if (row.grant.kind === "user") {
const [secret] = await db
.select()
.from(companySecrets)
.where(
and(
eq(companySecrets.id, ref.secretId),
eq(companySecrets.companyId, s.companyId),
),
)
.limit(1);
if (
!row.grant.subjectUserId ||
secret?.scope !== "user" ||
secret.ownerUserId !== row.grant.subjectUserId ||
!secret.userSecretDefinitionId
) {
throw forbidden("Personal browser credential ownership is invalid.");
}
}
const key = await secrets.resolveSecretValue(
s.companyId,
ref.secretId,
ref.versionSelector ?? "latest",
{
// The exact persisted grant is the authority, as in the gateway's
// grant resolver. Cleanup must survive removal of runtime bindings.
allowUserSecretScope: row.grant.kind === "user",
accessContext: {
consumerType: "tool_connection",
consumerId: s.connectionId,
configPath: ref.configPath,
responsibleUserId: row.grant.subjectUserId,
actorType: "system",
actorId: cleanup ? "browser-use-cloud-cleanup" : "browser-use-cloud",
},
},
);
return {
...row,
credentialSnapshot,
client: browserUseClient(
{ "X-Browser-Use-API-Key": key },
request,
s.connectionId,
),
};
}
async function owned(binding: Binding, grant: Grant, id: string) {
const [s] = await db
.select()
.from(sessions)
.where(
and(
eq(sessions.id, id),
eq(sessions.companyId, binding.companyId),
eq(sessions.issueId, binding.issueId!),
eq(sessions.agentId, binding.agentId!),
eq(sessions.grantId, grant.id),
eq(sessions.connectionId, grant.connectionId),
),
)
.limit(1);
if (!s)
throw notFound(
"Browser conversation not found for this task and credential.",
);
return s;
}
async function assertBinding(binding: Binding, approved = false) {
if (!binding.issueId || !binding.agentId || !binding.runId)
throw forbidden(
"Browser tasks require an active Paperclip task and agent run. Use browser_profiles for a connection test.",
);
const [issue] = await db
.select()
.from(issues)
.where(
and(
eq(issues.id, binding.issueId),
eq(issues.companyId, binding.companyId),
),
)
.limit(1);
const [run] = await db
.select()
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, binding.runId),
eq(heartbeatRuns.companyId, binding.companyId),
eq(heartbeatRuns.agentId, binding.agentId),
),
)
.limit(1);
if (
!issue ||
issue.assigneeAgentId !== binding.agentId ||
!run ||
(!approved && run.status !== "running")
)
throw forbidden(
"The browser's owning task or agent run is no longer active.",
);
if (
await issueTreeControlService(db).getActivePauseHoldGate(
binding.companyId,
issue.id,
)
)
throw forbidden("This task is paused.");
const blocked = await budgets.getInvocationBlock(
binding.companyId,
binding.agentId,
{ issueId: issue.id, projectId: issue.projectId },
);
if (blocked) throw forbidden(blocked.reason);
return issue;
}
async function getSettings(
companyId: string,
grantId: string,
): Promise<BrowserUseSettings> {
const [s] = await db
.select()
.from(settings)
.where(
and(eq(settings.grantId, grantId), eq(settings.companyId, companyId)),
)
.limit(1);
return {
allowedProfileIds: s?.allowedProfileIds ?? [],
maxCostUsd: s?.maxCostUsd ? Number(s.maxCostUsd) : null,
};
}
async function latest(s: Session) {
return (
await db
.select()
.from(runs)
.where(and(eq(runs.companyId, s.companyId), eq(runs.sessionId, s.id)))
.orderBy(desc(runs.createdAt))
.limit(1)
)[0];
}
async function snapshot(s: Session) {
const run = await latest(s);
const ownedBrowsers = await db
.select({ id: browsers.id, status: browsers.status })
.from(browsers)
.where(
and(eq(browsers.companyId, s.companyId), eq(browsers.sessionId, s.id)),
);
return {
sessionId: s.id,
status: s.status,
runStatus: run?.status ?? null,
progress: run?.progress ?? null,
result: publicResult(run?.result ?? null),
costCents: run?.accountedCents ?? 0,
browsers: ownedBrowsers,
error: s.error,
};
}
async function signal(
s: Session,
action: string,
actorId = "browser-use-cloud",
actorType: "system" | "user" = "system",
) {
await logActivity(db, {
companyId: s.companyId,
actorType,
actorId,
action,
entityType: "issue",
entityId: s.issueId,
details: { browserSessionId: s.id },
});
}
async function execute(
binding: Binding,
grant: Grant,
invocationId: string,
name: string,
value: unknown,
) {
const args = argsSchema.parse(value ?? {});
const config = await getSettings(binding.companyId, grant.id);
const scope = {
companyId: binding.companyId,
connectionId: grant.connectionId,
grantId: grant.id,
};
const selected = await credentials(scope);
const { client } = selected;
if (name === "browser_profiles") {
// A safe Test action that creates no paid work and exposes no unapproved profile.
if (!config.allowedProfileIds.length) return { profiles: [] };
const profiles: Array<{ id: string; name?: string | null }> = [];
for (let page = 1; page <= 100; page++) {
const list = await client.profiles(page);
profiles.push(
...list.items.filter((p) => config.allowedProfileIds.includes(p.id)),
);
if (page * 100 >= list.totalItems) break;
}
return { profiles: sanitizeBrowserUse(profiles) };
}
let approved = false;
if (binding.approvedInvocationId === invocationId) {
const [invocation] = await db
.select()
.from(toolInvocations)
.where(
and(
eq(toolInvocations.id, invocationId),
eq(toolInvocations.companyId, binding.companyId),
eq(toolInvocations.agentId, binding.agentId!),
eq(toolInvocations.runId, binding.runId!),
eq(toolInvocations.issueId, binding.issueId!),
eq(toolInvocations.connectionId, grant.connectionId),
eq(toolInvocations.status, "executing"),
eq(toolInvocations.approvalState, "approved"),
),
);
approved = Boolean(invocation);
}
await assertBinding(binding, approved);
if (name === "browser_sessions") {
const rows = await db
.select()
.from(sessions)
.where(
and(
eq(sessions.companyId, binding.companyId),
eq(sessions.issueId, binding.issueId!),
eq(sessions.agentId, binding.agentId!),
eq(sessions.grantId, grant.id),
),
);
return { sessions: await Promise.all(rows.map(snapshot)) };
}
if (name !== "browser_start") {
if (!args.sessionId)
throw conflict("Choose a browser conversation from this task.");
const s = await owned(binding, grant, args.sessionId);
if (name === "browser_status") {
// The signed approval dispatch owns the run until the resumed task
// observes it. Thereafter normal parent-run cancellation applies.
if (!approved)
await db
.update(runs)
.set({ heartbeatRunId: binding.runId!, detachedUntil: null })
.where(
and(
eq(runs.sessionId, s.id),
eq(runs.companyId, s.companyId),
sql`${runs.detachedUntil} is not null`,
),
);
return snapshot(s);
}
if (name === "browser_cancel" || name === "browser_end") {
await control(s, name === "browser_cancel" ? "cancel" : "end");
return snapshot(
(await db.select().from(sessions).where(eq(sessions.id, s.id)))[0],
);
}
if (name !== "browser_continue")
throw notFound("Unknown Browser Use action.");
}
if (!args.task) throw conflict("A browser task is required.");
if (
args.profileId &&
(!config.allowedProfileIds.includes(args.profileId) ||
name !== "browser_start")
)
throw forbidden("This profile is not allowed for this credential.");
const existing = await db
.select()
.from(runs)
.where(
and(
eq(runs.companyId, binding.companyId),
eq(runs.invocationId, invocationId),
),
)
.limit(1);
if (existing[0])
return snapshot(await owned(binding, grant, existing[0].sessionId));
const lockAdmissionScopes = async (tx: Db) => {
// Shared authority fences let concurrent paid claims proceed together.
// A company/project pause must precede the claim or see its durable run.
// Take these before browser/session/run rows, matching control writers.
const [scope] = await tx.select({ projectId: issues.projectId }).from(issues)
.where(and(eq(issues.id, binding.issueId!), eq(issues.companyId, binding.companyId)));
await lockBudgetAdmissionControls(tx, binding.companyId, scope?.projectId);
const [locked] = await tx.select({ projectId: issues.projectId }).from(issues)
.where(and(eq(issues.id, binding.issueId!), eq(issues.companyId, binding.companyId))).for("share");
if (locked?.projectId !== scope?.projectId) throw conflict("The browser task's project changed before dispatch.");
};
const admissionCheck = async (tx: Db, publications: ActivityPublication[], hold?: BrowserBudgetHold) => {
const [parent] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, binding.runId!), eq(heartbeatRuns.companyId, binding.companyId),
eq(heartbeatRuns.agentId, binding.agentId!))).for("update");
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, binding.issueId!), eq(issues.companyId, binding.companyId)));
const [currentAgent] = await tx.select().from(agents).where(and(eq(agents.id, binding.agentId!), eq(agents.companyId, binding.companyId)));
const [company] = await tx.select({ status: companies.status }).from(companies).where(eq(companies.id, binding.companyId));
if (!parent || (!approved && parent.status !== "running") || parent.resultJson?.cancellation || parent.resultJson?.startupCancellation
|| !issue || issue.assigneeAgentId !== binding.agentId || issue.status === "cancelled"
|| !currentAgent || currentAgent.lifecycleState !== "ready" || ["paused", "terminated", "pending_approval"].includes(currentAgent.status)
|| company?.status !== "active") return { reason: "The browser's owning task, company or agent run is no longer active." };
const [invocation] = await tx.select().from(toolInvocations).where(and(
eq(toolInvocations.id, invocationId), eq(toolInvocations.companyId, binding.companyId)));
if (invocation && (invocation.agentId !== binding.agentId || invocation.runId !== binding.runId
|| invocation.issueId !== binding.issueId || invocation.connectionId !== grant.connectionId
|| invocation.status !== "executing" || !["agent", "user", "system", "plugin"].includes(invocation.actorType)))
return { reason: "The browser invocation is no longer authorized." };
if (approved && invocation?.approvalState !== "approved") return { reason: "The browser approval is no longer authorized." };
if (await issueTreeControlService(tx).getActivePauseHoldGate(binding.companyId, issue.id)) return { reason: "This task is paused." };
const [current] = await tx.select({ grant: connectionGrants, connection: toolConnections }).from(connectionGrants)
.innerJoin(toolConnections, eq(toolConnections.id, connectionGrants.connectionId)).where(and(
eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, binding.companyId),
eq(toolConnections.id, grant.connectionId), eq(toolConnections.companyId, binding.companyId))).for("share");
const [secret] = await tx.select().from(companySecrets).where(and(eq(companySecrets.id, selected.credentialSnapshot.id),
eq(companySecrets.companyId, binding.companyId))).for("share");
// Reuse the gateway's policy evaluator, including profile precedence,
// exclusions and explicit denies. REST gallery tools do not need MCP
// installation rows, and a matching binding alone is not authorization.
const [entry] = await tx.select().from(toolCatalogEntries).where(and(
eq(toolCatalogEntries.companyId, binding.companyId), eq(toolCatalogEntries.connectionId, grant.connectionId),
eq(toolCatalogEntries.toolName, name))).limit(1);
if (!entry || (invocation?.catalogEntryId && invocation.catalogEntryId !== entry.id)
|| (invocation?.upstreamToolName && invocation.upstreamToolName !== name))
return { reason: "Browser tool is no longer available." };
const access = await toolAccessPolicyService(tx).decide({
companyId: binding.companyId, actor: { actorType: (invocation?.actorType ?? "agent") as "agent" | "user" | "system" | "plugin",
actorId: invocation?.actorId ?? binding.agentId!, agentId: binding.agentId },
runContext: { heartbeatRunId: binding.runId, issueId: issue.id, projectId: issue.projectId, gatewayId: binding.gatewayId ?? invocation?.gatewayId },
request: { applicationId: entry.applicationId, connectionId: grant.connectionId, catalogEntryId: entry.id,
toolName: invocation?.toolName ?? entry.name, upstreamToolName: name, riskLevel: entry.riskLevel,
applicationKey: invocation?.applicationKey, providerType: invocation?.providerType ?? "rest_api", arguments: value, sideEffecting: true },
consumeRateLimit: false, writeAuditEvent: false,
}, { admittedInvocationId: invocation?.id });
if (!access.allowed && !(approved && access.decision === "require_approval")) return { reason: access.explanation };
if (!current || current.grant.status !== "active" || current.connection.status !== "active" || !current.connection.enabled
|| !isBrowserUseConnection(current.connection)
|| current.grant.updatedAt.getTime() !== selected.grant.updatedAt.getTime()
|| current.connection.updatedAt.getTime() !== selected.connection.updatedAt.getTime()
|| JSON.stringify(current.grant.credentialSecretRefs) !== JSON.stringify(selected.grant.credentialSecretRefs)
|| !secret || secret.status !== "active" || secret.latestVersion !== selected.credentialSnapshot.latestVersion)
return { reason: "Browser access or its credential changed before dispatch." };
const admission = await budgetServiceInTransaction(tx, publications).evaluateAdmission(binding.companyId, binding.agentId, { projectId: issue.projectId });
if (admission.block) return { reason: admission.block.reason };
if (hold && centsToUnits(hold.amountCents) === 0n && admission.policies.length)
return { reason: "Browser budget changed before dispatch; try again with a bounded cost cap." };
const [currentConfig] = await tx.select().from(settings).where(and(eq(settings.companyId, binding.companyId), eq(settings.grantId, grant.id)));
if (args.profileId && !currentConfig?.allowedProfileIds.includes(args.profileId)) return { reason: "This profile is not allowed for this credential." };
let amount: bigint | undefined;
for (const cap of [currentConfig?.maxCostUsd, args.maxCostUsd]) {
if (cap === null || cap === undefined) continue;
const value = usdToUnits(cap);
amount = amount === undefined || value < amount ? value : amount;
}
for (const [index, policy] of admission.policies.entries()) {
const observed = admission.observations[index];
const committed = centsToUnits(observed.ledgerCents) + centsToUnits(observed.effectiveHeldCents);
const available = centsToUnits(policy.amount) - committed;
if (hold ? available < 0n || (centsToUnits(hold.amountCents) === 0n && available === 0n) : available <= 0n)
return { reason: "No browser budget remains; available capacity is reserved by unfinished work." };
if (!hold) amount = amount === undefined || available < amount ? available : amount;
}
if (amount !== undefined && amount <= 0n) return { reason: "No browser budget remains." };
if (hold && amount !== undefined && (centsToUnits(hold.amountCents) === 0n || amount < centsToUnits(hold.amountCents)))
return { reason: "Browser cost settings changed before dispatch; try again." };
if (hold && issue.projectId !== hold.projectId) return { reason: "The browser task's project changed before dispatch." };
return { amountCents: unitsToCents(amount ?? 0n), maxCostUsd: amount === undefined ? undefined : Number(centsToUsd(unitsToCents(amount))), projectId: issue.projectId };
};
const sid = args.sessionId;
const admitted = await withOperationAccountingTransaction(db, binding.companyId, async (tx, publications) => {
await lockAdmissionScopes(tx);
const [duplicate] = await tx.select().from(runs).where(and(eq(runs.companyId, binding.companyId), eq(runs.invocationId, invocationId)));
if (duplicate) return { existing: duplicate.sessionId };
const check = await admissionCheck(tx, publications);
if (check.reason) return { reason: check.reason };
await notifyDeliveryWork(tx, DELIVERY_QUEUES.browser);
let row: Session;
if (sid) {
await owned(binding, grant, sid);
const [updated] = await tx
.update(sessions)
.set({
status: "starting",
nextPollAt: new Date(),
idleDeadline: null,
updatedAt: new Date(),
})
.where(
and(
eq(sessions.id, sid),
eq(sessions.companyId, binding.companyId),
eq(sessions.status, "idle"),
isNull(sessions.stopRequested),
or(
isNull(sessions.leaseUntil),
lte(sessions.leaseUntil, new Date()),
),
),
)
.returning();
if (!updated)
throw conflict("This browser conversation is busy or has ended.");
row = updated;
} else {
[row] = await tx
.insert(sessions)
.values({
...scope,
issueId: binding.issueId!,
agentId: binding.agentId!,
status: "starting",
})
.returning();
}
const hold: BrowserBudgetHold = { invocationId, amountCents: check.amountCents!, projectId: check.projectId!,
state: "held", uncapped: check.maxCostUsd === undefined };
await tx.insert(runs).values({
result: { paperclipBudgetHold: hold },
companyId: binding.companyId,
sessionId: row.id,
heartbeatRunId: binding.runId!,
invocationId,
detachedUntil: approved
? new Date(Date.now() + BROWSER_USE_IDLE_MS)
: null,
});
await budgetServiceInTransaction(tx, publications).evaluateAffectedScopes(binding.companyId, binding.agentId, check.projectId!);
return { session: row, hold, maxCostUsd: check.maxCostUsd };
}).catch(async error => {
// Parallel retries can both observe no invocation in their snapshots.
// Roll back the losing session/hold and replay the committed owner.
if (!isUniqueViolation(error, "browser_use_runs_invocation_uq")) throw error;
const [duplicate] = await db.select().from(runs).where(and(
eq(runs.companyId, binding.companyId), eq(runs.invocationId, invocationId)));
if (!duplicate) throw error;
return { existing: duplicate.sessionId, reason: undefined, session: undefined, hold: undefined, maxCostUsd: undefined };
});
if (admitted.reason) throw forbidden(admitted.reason);
if (admitted.existing) return snapshot(await owned(binding, grant, admitted.existing));
const s = admitted.session!;
const maxCostUsd = admitted.maxCostUsd;
let providerAttempted = false;
let result: Awaited<ReturnType<typeof client.start>>;
try {
result = await client.start({
// The v4 API has no create idempotency key. An opaque marker lets a
// read-only recovery scan identify this exact request after a lost reply.
task: args.task + requestMarker(invocationId),
...(s.providerSessionId ? { sessionId: s.providerSessionId } : {}),
browserSettings: {
record: false,
// Live-verified v4 option; currently omitted from provider OpenAPI.
allowResizing: true,
...(args.profileId ? { profileId: args.profileId } : {}),
},
...(maxCostUsd === undefined ? {} : { maxCostUsd }),
}, async () => {
const reason = await withOperationAccountingTransaction(db, binding.companyId, async (tx, publications) => {
await lockAdmissionScopes(tx);
const [current] = await tx.select().from(runs).where(and(eq(runs.invocationId, invocationId), eq(runs.companyId, binding.companyId))).for("update");
const [currentSession] = await tx.select().from(sessions).where(and(eq(sessions.id, s.id), eq(sessions.companyId, binding.companyId))).for("update");
const hold = budgetHold(current?.result);
if (!currentSession || currentSession.status !== "starting" || currentSession.stopRequested) return "Browser session was stopped before dispatch.";
if (!current || current.status !== "creating" || current.providerRunId || hold?.state !== "held" || hold.dispatchCommittedAt)
return "Browser provider dispatch no longer owns this invocation.";
const check = await admissionCheck(tx, publications, hold);
if (check.reason) return check.reason;
await tx.update(runs).set({ result: { ...resultObject(current.result), paperclipBudgetHold: { ...hold, dispatchCommittedAt: new Date().toISOString() } } }).where(eq(runs.id, current.id));
await budgetServiceInTransaction(tx, publications).evaluateAffectedScopes(binding.companyId, binding.agentId, hold.projectId);
return null;
});
if (reason) throw new BrowserUseError(403, reason, 0, true);
providerAttempted = true;
});
const persistedSession = await withOperationAccountingTransaction(db, binding.companyId, async (tx) => {
const [currentRun] = await tx.select().from(runs).where(and(eq(runs.invocationId, invocationId),
eq(runs.companyId, binding.companyId))).for("update");
const [currentSession] = await tx.select().from(sessions).where(and(eq(sessions.id, s.id),
eq(sessions.companyId, binding.companyId))).for("update");
if (!currentRun || !currentSession || (currentRun.providerRunId && currentRun.providerRunId !== result.id))
throw conflict("Browser provider response does not match its invocation.");
await notifyDeliveryWork(tx, DELIVERY_QUEUES.browser);
await tx
.update(runs)
.set({ providerRunId: result.id, status: browserUseTerminal(currentRun.status) ? currentRun.status : result.status })
.where(eq(runs.id, currentRun.id));
const [updated] = await tx
.update(sessions)
.set({
providerSessionId: result.sessionId,
status: currentSession.stopRequested || currentSession.status === "stopping" ? "stopping" : currentSession.status === "closed" ? "closed" : "running",
error: currentSession.stopRequested ? currentSession.error : null,
nextPollAt: new Date(),
updatedAt: new Date(),
})
.where(eq(sessions.id, s.id)).returning();
return updated;
});
Object.assign(s, persistedSession);
} catch (error) {
// A lost create response can mean paid work exists. Never submit it again.
const rejected = !providerAttempted || (error instanceof BrowserUseError && error.requestRejected);
const message = rejected ? (error instanceof Error ? error.message : "Browser dispatch was rejected.") :
"Browser run creation could not be confirmed. Paperclip is locating and stopping possible provider work. Do not start another run yet.";
await withOperationAccountingTransaction(db, binding.companyId, async (tx, publications) => {
const [currentRun] = await tx.select().from(runs).where(and(eq(runs.invocationId, invocationId),
eq(runs.companyId, binding.companyId))).for("update");
const [current] = await tx.select().from(sessions).where(and(eq(sessions.id, s.id), eq(sessions.companyId, binding.companyId))).for("update");
if (!currentRun || !current || currentRun.providerRunId || currentRun.eventsDrained || budgetHold(currentRun.result)?.state === "settled") return;
await notifyDeliveryWork(tx, DELIVERY_QUEUES.browser);
if (rejected) {
await tx.update(runs).set({ result: sql`jsonb_set(${runs.result}, '{paperclipBudgetHold,state}', '"released"'::jsonb)` }).where(eq(runs.invocationId, invocationId));
await budgetServiceInTransaction(tx, publications).evaluateAffectedScopes(binding.companyId, binding.agentId, admitted.hold!.projectId);
} else {
await tx.update(runs).set({ result: sql`jsonb_set(${runs.result}, '{paperclipBudgetHold,billingUnknown}', 'true'::jsonb)` }).where(eq(runs.invocationId, invocationId));
await budgetServiceInTransaction(tx, publications).evaluateAffectedScopes(binding.companyId, binding.agentId, admitted.hold!.projectId);
}
// A restart must not leave a drained rejection attached to a starting
// session: that combination has neither recovery work nor an idle timer.
await tx.update(runs)
.set({ status: rejected ? "failed" : "unknown", eventsDrained: rejected ? 1 : 0 })
.where(eq(runs.invocationId, invocationId));
const stopping = !rejected || current.stopRequested === "end";
await tx.update(sessions).set({
status: stopping ? "stopping" : (s.providerSessionId ? "idle" : "failed"),
stopRequested: stopping ? "end" : null,
idleDeadline: rejected && s.providerSessionId ? new Date(Date.now() + BROWSER_USE_IDLE_MS) : null,
error: message,
nextPollAt: new Date(),
updatedAt: new Date(),
}).where(eq(sessions.id, s.id));
});
throw new BrowserUseError(
error instanceof BrowserUseError ? error.status : 502,
message,
);
}
// An audit failure must not overwrite a successfully persisted paid run.
await signal(s, "browser.started");
return {
sessionId: s.id,
status: s.stopRequested || s.status === "stopping" ? "stopping" : result.status,
instruction:
"Poll browser_status until terminal. The human's Browser tab opens when the browser is ready.",
};
}
async function control(
s: Session,
action: BrowserUseControl,
userId?: string,
) {
if (action === "keep_open") {
const updated = await db
.update(sessions)
.set({
idleDeadline: new Date(Date.now() + BROWSER_USE_IDLE_MS),
updatedAt: new Date(),
})
.where(
and(
eq(sessions.id, s.id),
eq(sessions.companyId, s.companyId),
eq(sessions.status, "idle"),
isNull(sessions.stopRequested),
or(
isNull(sessions.leaseUntil),
lte(sessions.leaseUntil, new Date()),
),
),
)
.returning();
if (!updated.length)
throw conflict("Only an idle browser can be kept open.");
} else {
if (terminalSessions.includes(s.status)) return;
await db.transaction(async tx => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.browser);
await tx
.update(sessions)
.set({
stopRequested: action,
...(action === "end" ? { status: "stopping" } : {}),
nextPollAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(sessions.id, s.id),
action === "cancel"
? or(
isNull(sessions.stopRequested),
ne(sessions.stopRequested, "end"),
)
: undefined,
),
);
});
}
await signal(
s,
`browser.${action}_requested`,
userId,
userId ? "user" : "system",
);
}
async function humanGrant(
companyId: string,
grantId: string,
userId: string,
cleanup = false,
) {
const [grant] = await db
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.id, grantId),
eq(connectionGrants.companyId, companyId),
),
)
.limit(1);
if (!grant || (!cleanup && grant.status !== "active"))
throw forbidden("Browser credential access is unavailable.");
const [membership] = await db
.select({
id: companyMemberships.id,
role: companyMemberships.membershipRole,
})
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
)
.limit(1);
if ((!membership || membership.role === "viewer") && userId !== "board")
throw forbidden("Company membership is no longer active.");
if (grant.kind === "agent" && grant.createdByUserId === userId)
return grant;
if (grant.kind === "user" && grant.subjectUserId === userId) return grant;
if (grant.kind !== "organization")
throw forbidden("This browser belongs to another credential.");
const members = await db
.select()
.from(connectionGrantMembers)
.where(
and(
eq(connectionGrantMembers.companyId, companyId),
eq(connectionGrantMembers.grantId, grantId),
),
);
if (
members.length &&
!members.some((m) => m.subjectType === "user" && m.subjectId === userId)
)
throw forbidden("This browser credential is not shared with you.");
return grant;
}
async function list(
companyId: string,
issueId: string,
userId: string,
): Promise<TaskBrowser[]> {
const rows = await db
.select({ s: sessions, b: browsers })
.from(sessions)
.leftJoin(browsers, eq(browsers.sessionId, sessions.id))
.where(
and(eq(sessions.companyId, companyId), eq(sessions.issueId, issueId)),
)
.orderBy(sessions.createdAt);
const out: TaskBrowser[] = [];
for (const { s, b } of rows) {
try {
await humanGrant(companyId, s.grantId, userId);
} catch {
continue;
}
const run = await latest(s);
out.push({
id: b?.id ?? s.id,
sessionId: s.id,
issueId,
status:
b?.status === "stopped"
? "closed"
: !b && !terminalSessions.includes(s.status) && s.status !== "stopping"
? "starting"
: (s.status as TaskBrowser["status"]),
runStatus: run?.status ?? null,
progress: run?.progress ?? null,
costCents: run?.accountedCents ?? 0,
idleDeadline: s.idleDeadline?.toISOString() ?? null,
expiresAt: b?.expiresAt?.toISOString() ?? null,
error: s.error,
createdAt: (b?.createdAt ?? s.createdAt).toISOString(),
});
}
return out;
}
async function humanSession(
companyId: string,
issueId: string,
browserId: string,
userId: string,
) {
const [b] = await db
.select()
.from(browsers)
.where(and(eq(browsers.id, browserId), eq(browsers.companyId, companyId)))
.limit(1);
const [s] = await db
.select()
.from(sessions)
.where(
and(
eq(sessions.id, b?.sessionId ?? browserId),
eq(sessions.companyId, companyId),
eq(sessions.issueId, issueId),
),
)
.limit(1);
if (!s) throw notFound("Browser not found.");
await humanGrant(companyId, s.grantId, userId);
return { s, b };
}
async function viewer(
companyId: string,
issueId: string,
browserId: string,
userId: string,
viewerId?: string,
) {
const { s, b } = await humanSession(companyId, issueId, browserId, userId);
if (
!b ||
b.status !== "active" ||
terminalSessions.includes(s.status) ||
s.stopRequested === "end"
)
throw conflict("This browser is not available. It may have expired.");
const { client } = await credentials(s);
const result = await client.browser(b.providerBrowserId);
if (
result.status !== "active" ||
result.agentSessionId !== s.providerSessionId
)
throw conflict("This browser is no longer active.");
const viewportState = await browserUseViewports.current(
b.providerBrowserId,
`${userId}:${viewerId ?? ""}`,
);
return {
url: browserUseViewerUrl(result.liveUrl),
viewport: viewportState.preset,
viewportState,
};
}
async function presence(
companyId: string,
issueId: string,
browserId: string,
userId: string,
) {
const { s, b } = await humanSession(companyId, issueId, browserId, userId);
if (
!b ||
b.status !== "active" ||
!["running", "idle"].includes(s.status) ||
s.stopRequested === "end"
)
throw conflict("This browser is no longer active.");
// Presence only renews existing idle browsers. It never starts paid work,
// overrides a stop/revocation, or extends the provider's absolute lifetime.
if (s.status !== "idle") return { accepted: true };
const now = Date.now();
const deadline = new Date(
Math.min(now + BROWSER_USE_IDLE_MS, b.expiresAt?.getTime() ?? Infinity),
);
if (
s.idleDeadline &&
s.idleDeadline.getTime() >= deadline.getTime() - 60_000
)
return { accepted: true };
const updated = await db
.update(sessions)
.set({ idleDeadline: deadline, updatedAt: new Date(now) })
.where(
and(
eq(sessions.id, s.id),
eq(sessions.companyId, companyId),
eq(sessions.status, "idle"),
isNull(sessions.stopRequested),
// The reconciler holds this lease while deciding whether to close.
or(
isNull(sessions.leaseUntil),
lte(sessions.leaseUntil, new Date(now)),
),
),
)
.returning();
if (updated.length) await signal(s, "browser.viewing", userId, "user");
return { accepted: updated.length > 0 };
}
async function resize(
companyId: string,
issueId: string,
browserId: string,
userId: string,
request: BrowserUseViewportRequest,
) {
const { s, b } = await humanSession(companyId, issueId, browserId, userId);
if (
!b ||
b.status !== "active" ||
!["running", "idle"].includes(s.status) ||
s.stopRequested === "end"
)
throw conflict("This browser is not available. It may have expired.");
const { client } = await credentials(s);
const detail = await client.browser(b.providerBrowserId);
if (
detail.status !== "active" ||
detail.agentSessionId !== s.providerSessionId
)
throw conflict("This browser is no longer active.");
if (!detail.cdpUrl)
throw conflict(
"Browser resizing is unavailable. Reconnect the view and try again.",
);
const result = await browserUseViewports.resize(
b.providerBrowserId,
detail.cdpUrl,
request,
detail.timeoutAt,
`${userId}:${request.viewerId ?? ""}`,
);
if (result.applied)
await logActivity(db, {
companyId,
actorType: "user",
actorId: userId,
action: "browser.resized",
entityType: "issue",
entityId: issueId,
details: {
browserSessionId: s.id,
browserId,
preset: result.preset,
width: result.width,
height: result.height,
},
});
return result;
}
async function releaseViewport(
companyId: string,
issueId: string,
browserId: string,
userId: string,
viewerId: string,
) {
const { b } = await humanSession(companyId, issueId, browserId, userId);
if (b)
await browserUseViewports.releaseLease(
b.providerBrowserId,
`${userId}:${viewerId}`,
);
return { accepted: true };
}
async function discoverBrowsers(
s: Session,
client: ReturnType<typeof browserUseClient>,
) {
if (!s.providerSessionId) return;
for (let page = 1; page <= 100; page++) {
const result = await client.browsers(s.providerSessionId, page);
for (const item of result.items) {
// List is scoped by session server-side. Check full detail too before
// registering an instance; never infer ownership from an event URL.
const detail = await client.browser(item.id);
if (detail.agentSessionId !== s.providerSessionId) continue;
if (detail.status === "stopped")
await browserUseViewports.release(item.id);
const rows = await db
.insert(browsers)
.values({
companyId: s.companyId,
sessionId: s.id,
providerBrowserId: item.id,
status: detail.status,
expiresAt: detail.timeoutAt ? new Date(detail.timeoutAt) : null,
})
.onConflictDoNothing()
.returning();
if (rows.length) await signal(s, "browser.ready");
await db
.update(browsers)
.set({ status: detail.status })
.where(
and(
eq(browsers.companyId, s.companyId),
eq(browsers.sessionId, s.id),
eq(browsers.providerBrowserId, item.id),
),
);
}
if (page * 100 >= result.totalItems) break;
}
}
async function account(
s: Session,
run: typeof runs.$inferSelect,
summary?: Awaited<ReturnType<ReturnType<typeof browserUseClient>["summary"]>>,
) {
let candidate: { amount: bigint; model: string; result: Record<string, unknown> } | undefined;
if (summary) {
let amount: bigint;
try { amount = usdToUnits(summary.totalCostUsd); }
catch { throw new BrowserUseError(502, "Browser Use returned invalid usage."); }
if (amount < 0n || !browserUseTerminal(summary.status))
throw new BrowserUseError(502, "Browser Use returned invalid terminal usage.");
candidate = { amount, model: summary.model,
result: resultObject(sanitizeBrowserUse({ result: summary.result, output: summary.output })) };
}
// Persist the provider observation before attempting the ledger transaction.
// A failed/uncertain commit can then recover its exact price, attribution and
// occurrence time without depending on another provider response.
for (;;) {
const receipt = await withOperationAccountingTransaction(db, s.companyId, async (tx, publications) => {
const [current] = await tx.select().from(runs).where(and(eq(runs.id, run.id), eq(runs.companyId, s.companyId))).for("update");
if (!current || current.sessionId !== s.id || !current.providerRunId || current.providerRunId !== run.providerRunId)
throw conflict("Browser usage no longer belongs to this run.");
const hold = budgetHold(current.result);
if (hold && hold.invocationId !== current.invocationId) throw conflict("Browser accounting identity does not match its invocation.");
const previous = centsToUnits(hold?.accountedCentsExact ?? current.accountedCents);
const saved = hold?.receipt;
if (saved && (saved.invocationId !== current.invocationId || saved.providerRunId !== current.providerRunId
|| !Number.isFinite(new Date(saved.occurredAt).getTime()) || centsToUnits(saved.totalCentsExact) < 0n))
throw conflict("Browser usage receipt identity is invalid.");
if (saved && (centsToUnits(saved.totalCentsExact) > previous || hold?.state === "held")) return saved;
if (!candidate) return null;
if (candidate.amount <= previous && hold?.state !== "held") {
if (!current.eventsDrained) await tx.update(runs).set({ eventsDrained: 1 }).where(eq(runs.id, current.id));
return null;
}
const [issue] = await tx.select({ projectId: issues.projectId }).from(issues)
.where(and(eq(issues.id, s.issueId), eq(issues.companyId, s.companyId)));
const projectId = hold ? hold.projectId : issue?.projectId ?? null;
const captured: BrowserUsageReceipt = { invocationId: current.invocationId, providerRunId: current.providerRunId,
totalCentsExact: unitsToCents(candidate.amount), model: candidate.model, occurredAt: new Date().toISOString() };
const remaining = candidate.amount > previous ? candidate.amount - previous : 0n;
const held = hold?.state === "held" ? centsToUnits(hold.amountCents) : 0n;
await tx.update(runs).set({ eventsDrained: 0, result: { ...candidate.result, paperclipBudgetHold: {
...(hold ?? { invocationId: current.invocationId, projectId }), state: "held",
amountCents: unitsToCents(held > remaining ? held : remaining), accountedCentsExact: unitsToCents(previous), receipt: captured,
} } }).where(eq(runs.id, current.id));
await budgetServiceInTransaction(tx, publications).evaluateAffectedScopes(s.companyId, s.agentId, projectId);
return captured;
});
if (!receipt) return;
const amount = centsToUnits(receipt.totalCentsExact);
await withBudgetSpan("budget.accounting.settlement", () => withOperationAccountingTransaction(db, s.companyId, async (tx, publications) => {
const [current] = await tx.select().from(runs).where(and(eq(runs.id, run.id), eq(runs.companyId, s.companyId))).for("update");
const hold = current && budgetHold(current.result);
if (!current || current.sessionId !== s.id || !hold || hold.invocationId !== receipt.invocationId
|| current.providerRunId !== receipt.providerRunId) throw conflict("Browser usage no longer belongs to this run.");
const previous = centsToUnits(hold.accountedCentsExact ?? current.accountedCents);
if (hold.receipt?.totalCentsExact !== receipt.totalCentsExact || hold.receipt.occurredAt !== receipt.occurredAt) return;
// Another reconciler may already have committed this receipt (or a later
// cumulative observation). Its local operation fence makes that replay.
if (amount <= previous && hold.state === "settled") return;
const total = amount > previous ? amount : previous;
const settled: BrowserBudgetHold = { ...hold, state: "settled", billingUnknown: false,
accountedCentsExact: unitsToCents(total) };
await tx.update(runs).set({
result: { ...resultObject(current.result), paperclipBudgetHold: settled }, eventsDrained: 1,
accountedCents: Math.min(2_147_483_647, Math.round(Number(unitsToCents(total)))),
}).where(eq(runs.id, current.id));
if (amount <= previous) {
await budgetServiceInTransaction(tx, publications).evaluateAffectedScopes(s.companyId, s.agentId, hold.projectId);
return;
}
const e = await createCostEventInTransaction(tx, s.companyId, {
agentId: s.agentId, issueId: s.issueId, projectId: hold.projectId,
heartbeatRunId: current.heartbeatRunId,
provider: "browser-use-cloud", biller: "browser-use-cloud", billingType: "metered_api", model: receipt.model,
costCents: unitsToCents(amount - previous), occurredAt: new Date(receipt.occurredAt),
billingCode: `browser-use-cloud:${current.id}`, idempotencyKey: `browser-use-cloud:${current.id}:${receipt.totalCentsExact}`,
}, publications);
await createFinanceEventInTransaction(tx, publications, s.companyId, {
agentId: e.agentId, issueId: e.issueId, projectId: e.projectId, heartbeatRunId: e.heartbeatRunId,
costEventId: e.id, billingCode: e.billingCode, eventKind: "inference_charge", direction: "debit",
biller: "browser-use-cloud", provider: "browser-use-cloud", model: e.model,
amountCents: e.costCentsExact, currency: "USD", description: "Browser Use agent run", occurredAt: e.occurredAt,
idempotencyKey: `browser-use-cloud:${current.id}:${receipt.totalCentsExact}`,
});
}), { "budget.accounting.owner": "browser" });
if (!candidate || amount >= candidate.amount) return;
}
}
const sessionLeaseFence = (s: Session) => and(eq(sessions.id, s.id), eq(sessions.companyId, s.companyId),
s.leaseToken ? eq(sessions.leaseToken, s.leaseToken) : sql`false`, sql`${sessions.leaseUntil} > clock_timestamp()`);
const runLeaseFence = (s: Session, runId: string) => and(eq(runs.id, runId), eq(runs.companyId, s.companyId),
sql`exists (select 1 from ${sessions} where ${sessionLeaseFence(s)})`);
async function reconcile(s: Session) {
const { grant, connection, client } = await credentials(s, true);
let end =
s.stopRequested === "end" ||
grant.status !== "active" ||
!connection.enabled ||
connection.status !== "active" ||
(s.idleDeadline !== null && s.idleDeadline.getTime() <= Date.now());
const [issue] = await db
.select()
.from(issues)
.where(and(eq(issues.id, s.issueId), eq(issues.companyId, s.companyId)));
const [agent] = await db
.select()
.from(agents)
.where(and(eq(agents.id, s.agentId), eq(agents.companyId, s.companyId)));
if (
!issue ||
issue.assigneeAgentId !== s.agentId ||
issue.status === "cancelled" ||
agent?.status === "paused" ||
agent?.status === "terminated"
)
end = true;
if (
await issueTreeControlService(db).getActivePauseHoldGate(
s.companyId,
s.issueId,
)
)
end = true;
if (
await budgets.getInvocationBlock(s.companyId, s.agentId, {
issueId: s.issueId,
projectId: issue?.projectId,
})
)
end = true;
const allRuns = await db
.select()
.from(runs)
.where(and(eq(runs.sessionId, s.id), eq(runs.companyId, s.companyId)))
.orderBy(desc(runs.createdAt));
if (
allRuns[0] &&
authorizeSession &&
!(await authorizeSession(s, allRuns[0]))
)
end = true;
let active = false;
let pendingAccounting = false;
let discovered = false;
for (const run of allRuns) {
const [ownedLease] = await db.select({ id: sessions.id }).from(sessions).where(sessionLeaseFence(s));
if (!ownedLease) return;
if (!run.providerRunId) {
if (run.eventsDrained) continue; // A definitive rejection made no paid run.
if (run.status === "creating" && Date.now() - run.createdAt.getTime() <= 60_000) {
active = true;
continue;
}
if (budgetHold(run.result)?.state === "held" && !budgetHold(run.result)?.dispatchCommittedAt) {
const repaired = await withOperationAccountingTransaction(db, s.companyId, async (tx, publications) => {
const [current] = await tx.select().from(runs).where(and(eq(runs.id, run.id), eq(runs.companyId, s.companyId))).for("update");
const hold = budgetHold(current?.result);
if (!current || current.providerRunId || current.status !== "creating" || hold?.state !== "held" || hold.dispatchCommittedAt
|| Date.now() - current.createdAt.getTime() <= 60_000) return null;
const [session] = await tx.select().from(sessions).where(sessionLeaseFence(s)).for("update");
if (!session) return null;
await notifyDeliveryWork(tx, DELIVERY_QUEUES.browser);
await tx.update(runs).set({ status: "failed", eventsDrained: 1,
result: { ...resultObject(current.result), paperclipBudgetHold: { ...hold, state: "released" } } }).where(runLeaseFence(s, run.id));
const stopping = session.stopRequested === "end";
const [updated] = await tx.update(sessions).set({
status: stopping ? "stopping" : session.providerSessionId ? "idle" : "failed",
idleDeadline: !stopping && session.providerSessionId ? new Date(Date.now() + BROWSER_USE_IDLE_MS) : null,
updatedAt: new Date(),
}).where(sessionLeaseFence(s)).returning();
await budgetServiceInTransaction(tx, publications).evaluateAffectedScopes(s.companyId, s.agentId, hold.projectId);
return updated;
});
if (repaired) {
Object.assign(s, repaired);
run.eventsDrained = 1;
run.status = "failed";
if (terminalSessions.includes(s.status)) return;
continue;
}
}
end = true;
await db.update(sessions).set({ status: "stopping", stopRequested: "end" })
.where(sessionLeaseFence(s));
let cursor = run.recoveryCursor;
for (let page = 0; page < 5 && !run.providerRunId; page++) {
const listed = await client.listRuns(s.providerSessionId, cursor);
const matches = listed.runs.filter(candidate =>
candidate.task.endsWith(requestMarker(run.invocationId)) &&
(!s.providerSessionId || candidate.sessionId === s.providerSessionId));
if (matches.length > 1) throw new BrowserUseError(502, "Browser recovery found ambiguous provider work.");
const found = matches[0];
if (found) {
await db.transaction(async tx => {
await tx.update(runs).set({ providerRunId: found.id, status: found.status, recoveryCursor: null })
.where(and(runLeaseFence(s, run.id), isNull(runs.providerRunId)));
await tx.update(sessions).set({ providerSessionId: found.sessionId })
.where(sessionLeaseFence(s));
});
run.providerRunId = found.id;
run.status = found.status;
s.providerSessionId = found.sessionId;
break;
}
if (listed.hasMore && (!listed.nextCursor || listed.nextCursor === cursor))
throw new BrowserUseError(502, "Browser recovery cursor did not advance.");
cursor = listed.hasMore ? listed.nextCursor! : null;
await db.update(runs).set({ status: "unknown", recoveryCursor: cursor })
.where(and(runLeaseFence(s, run.id), isNull(runs.providerRunId)));
if (!listed.hasMore) break;
}
if (!run.providerRunId) {
// Absence from a list cannot prove a timed-out POST was rejected.
// Keep cleanup pending and retain its credential for later discovery.
active = true;
continue;
}
}
const [parent] = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, run.heartbeatRunId));
if (
run.id === allRuns[0].id &&
parent &&
["failed", "cancelled", "timed_out"].includes(parent.status)
)
end = true;
if (browserUseTerminal(run.status) && run.eventsDrained) continue;
let state = await client.status(run.providerRunId);
if (
run.id === allRuns[0].id &&
!browserUseTerminal(state.status) &&
(issue?.status === "done" ||
(parent?.status !== "running" &&
(!run.detachedUntil || run.detachedUntil.getTime() <= Date.now())))
)
end = true;
if (
(end || s.stopRequested === "cancel") &&
!browserUseTerminal(state.status)
) {
await client.cancel(run.providerRunId);
state = await client.status(run.providerRunId);
}
active ||= !browserUseTerminal(state.status);
let after = run.eventCursor;
let drained = false;
// Bounded pages yield fairly across sessions. Cursor commits on every page.
for (let page = 0; page < 5; page++) {
const events = await client.events(run.providerRunId, after);
const fresh = events.events.filter((e) => e.id > after);
if (!discovered && fresh.some((e) => e.type === "browser.ready")) {
await discoverBrowsers(s, client);
discovered = true;
}
const cursor = Math.max(
after,
events.nextAfter ?? 0,
...fresh.map((e) => e.id),
);
const progress = fresh.at(-1)?.type ?? run.progress;
await db
.update(runs)
.set({
eventCursor: sql`greatest(${runs.eventCursor}, ${cursor})`,
progress: progress
? String(sanitizeBrowserUse(progress)).slice(0, 200)
: null,
})
.where(runLeaseFence(s, run.id));
if (!events.hasMore) {
drained = true;
break;
}
if (cursor <= after)
throw new BrowserUseError(
502,
"Browser Use event cursor did not advance.",
);
after = cursor;
}
await db
.update(runs)
.set({ status: sql`case when ${runs.status} in ('completed', 'failed', 'cancelled') then ${runs.status} else ${state.status} end` })
.where(runLeaseFence(s, run.id));
pendingAccounting ||= browserUseTerminal(state.status) && !drained;
if (browserUseTerminal(state.status) && drained) {
const hold = budgetHold(run.result);
const saved = hold?.receipt;
const pendingReceipt = hold && saved && (centsToUnits(saved.totalCentsExact) > centsToUnits(hold.accountedCentsExact ?? run.accountedCents) || hold.state === "held");
const summary = pendingReceipt ? undefined : await client.summary(run.providerRunId);
await account(s, run, summary);
await signal(s, "browser.run_finished");
}
}
if (!discovered) await discoverBrowsers(s, client);
if (end) {
await db
.update(sessions)
.set({ status: "stopping", stopRequested: "end" })
.where(sessionLeaseFence(s));
const live = await db
.select()
.from(browsers)
.where(
and(eq(browsers.sessionId, s.id), eq(browsers.status, "active")),
);
for (const b of live) {
await client.stop(b.providerBrowserId);
const confirmed = await client.browser(b.providerBrowserId);
if (confirmed.status !== "stopped")
throw new BrowserUseError(
502,
"Browser shutdown is not confirmed. Paperclip will retry.",
);
await db
.update(browsers)
.set({ status: "stopped" })
.where(eq(browsers.id, b.id));
}
// A cancelling run can spawn another browser before exiting; keep sweeping.
if (!active && !pendingAccounting) {
await db
.update(sessions)
.set({ status: "closed", error: null, stopRequested: null })
.where(sessionLeaseFence(s));
await signal(s, "browser.closed");
}
} else if (
!active &&
allRuns.length &&
allRuns.every((r) => r.providerRunId || r.eventsDrained)
) {
await db
.update(sessions)
.set({
status: "idle",
idleDeadline:
s.idleDeadline ?? new Date(Date.now() + BROWSER_USE_IDLE_MS),
stopRequested: null,
error: null,
})
.where(
and(
sessionLeaseFence(s),
ne(sessions.status, "starting"),
or(
isNull(sessions.stopRequested),
eq(sessions.stopRequested, "cancel"),
),
eq(sessions.updatedAt, s.updatedAt),
),
);
}
}
// Wait for a concurrent sweep rather than reporting completion while its
// lease is still held. A filtered cleanup must still inspect its own scope.
let sweeping: Promise<void> | null = null;
async function sweep(connectionId?: string): Promise<void> {
if (sweeping) {
await sweeping;
return sweep(connectionId);
}
sweeping = reconcileDue(connectionId);
try { await sweeping; } finally { sweeping = null; }
}
async function reconcileDue(connectionId?: string) {
const due = await db
.select()
.from(sessions)
.where(
and(
connectionId ? eq(sessions.connectionId, connectionId) : undefined,
inArray(sessions.status, liveSessions),
lte(sessions.nextPollAt, new Date()),
or(
isNull(sessions.leaseUntil),
lte(sessions.leaseUntil, new Date()),
),
),
)
.limit(30);
for (const s of due) {
const token = randomUUID();
const lease = await db
.update(sessions)
.set({
leaseToken: token,
leaseUntil: new Date(Date.now() + 180_000),
})
.where(
and(
eq(sessions.id, s.id),
or(
isNull(sessions.leaseUntil),
lte(sessions.leaseUntil, new Date()),
),
),
)
.returning();
if (!lease.length) continue;
Object.assign(s, lease[0]);
// Renew while paginating provider events or stopping multiple browsers.
const renewal = setInterval(() => {
void db
.update(sessions)
.set({ leaseUntil: new Date(Date.now() + 180_000) })
.where(and(eq(sessions.id, s.id), eq(sessions.leaseToken, token)))
.catch(() => {});
}, 30_000);
renewal.unref?.();
let delay = s.status === "idle" ? 15000 : 3000;
try {
await reconcile(s);
} catch (e) {
delay = Math.max(
10000,
e instanceof BrowserUseError ? e.retryAfterMs : 0,
);
await db
.update(sessions)
.set({
error:
s.stopRequested === "end"
? "Browser shutdown is not confirmed. Paperclip will retry."
: "Browser synchronization failed. Paperclip will retry.",
})
.where(sessionLeaseFence(s));
} finally {
clearInterval(renewal);
const release = (tx: Pick<Db, "update">) => tx.update(sessions).set({
leaseToken: null,
leaseUntil: null,
nextPollAt: new Date(Date.now() + delay + Math.floor(Math.random() * 500)),
}).where(and(eq(sessions.id, s.id), eq(sessions.leaseToken, token)));
if (connectionId) {
// Credential-removal sweeps can race the app worker. Wake it when
// their lease ends so it does not wait for the old lease expiry.
await db.transaction(async tx => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.browser);
await release(tx);
});
} else {
await release(db);
}
}
}
}
async function nextSweepAt(): Promise<number | null> {
const [row] = await db.select({
// An overdue row owned by another sweep cannot run before its lease ends.
at: sql<string | null>`min(greatest(${sessions.nextPollAt}, ${sessions.leaseUntil}))`,
}).from(sessions).where(inArray(sessions.status, liveSessions));
return row?.at == null ? null : new Date(row.at).getTime();
}
return {
execute,
sweep,
nextSweepAt,
list,
viewer,
resize,
humanGrant,
humanSession,
releaseViewport,
presence,
control,
getSettings,
async stopBeforeCredentialRemoval(companyId: string, connectionId: string) {
const scope = and(
eq(sessions.companyId, companyId),
eq(sessions.connectionId, connectionId),
inArray(sessions.status, liveSessions),
);
await db.transaction(async tx => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.browser);
await tx
.update(sessions)
.set({
stopRequested: "end",
nextPollAt: new Date(),
updatedAt: new Date(),
})
.where(scope);
});
await sweep(connectionId);
const [pending] = await db
.select({ id: sessions.id })
.from(sessions)
.where(scope)
.limit(1);
if (pending)
throw conflict(
"Browser access is disabled. Shutdown is still pending; retry removal after the browsers stop. Paperclip retains the key only for cleanup until then.",
);
},
async availableProfiles(
companyId: string,
grantId: string,
userId: string,
) {
const grant = await humanGrant(companyId, grantId, userId);
const { client } = await credentials({
companyId,
grantId,
connectionId: grant.connectionId,
});
const profiles: Array<{ id: string; name: string | null }> = [];
for (let page = 1; page <= 100; page++) {
const result = await client.profiles(page);
profiles.push(
...result.items.map((p) => ({
id: p.id,
name: p.name ? String(sanitizeBrowserUse(p.name)) : null,
})),
);
if (page * 100 >= result.totalItems) break;
}
return profiles;
},
async saveSettings(
companyId: string,
grantId: string,
userId: string,
value: BrowserUseSettings,
) {
const grant = await humanGrant(companyId, grantId, userId);
const managesShared =
grant.kind === "organization" &&
(userId === "board" ||
(await accessService(db).hasPermission(
companyId,
"user",
userId,
"tools:manage_connections",
)));
if (
!managesShared &&
grant.createdByUserId !== userId &&
grant.subjectUserId !== userId
)
throw forbidden(
"Only the credential owner or a shared connection manager can change browser settings.",
);
const { client } = await credentials({
companyId,
grantId,
connectionId: grant.connectionId,
});
if (value.allowedProfileIds.length) {
const available = new Set<string>();
for (let page = 1; page <= 100; page++) {
const list = await client.profiles(page);
for (const p of list.items) available.add(p.id);
if (page * 100 >= list.totalItems) break;
}
if (value.allowedProfileIds.some((id) => !available.has(id)))
throw forbidden(
"A selected profile is not available to this credential.",
);
}
const row = {
companyId,
grantId,
allowedProfileIds: value.allowedProfileIds,
maxCostUsd: value.maxCostUsd?.toString() ?? null,
};
await db
.insert(settings)
.values(row)
.onConflictDoUpdate({ target: settings.grantId, set: row });
await logActivity(db, {
companyId,
actorType: "user",
actorId: userId,
action: "browser.settings_updated",
entityType: "tool_connection",
entityId: grant.connectionId,
details: { grantId },
});
return value;
},
};
}