mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-16 05:17:41 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent Chat uses the shared task surface and browser panel. > - An unsaved chat has a temporary ID instead of a stored task UUID. > - The browser poll sent that ID to a PostgreSQL UUID query and returned a server error. > - This pull request waits for a saved task and validates task IDs before the query. > - Saved chats keep their browser tools and access checks. ## Linked Issues or Issue Description Refs #14331. That report describes the same draft-ID problem in email requests. This change covers the browser routes; it does not change email behavior. Opening an unsaved Agent Chat sends `chat:<agent-id>` to `/issues/:issueId/browsers` every three seconds. PostgreSQL rejects this as a task UUID. The draft is only a UI view model. The first send or upload creates its stored task. ## What Changed - Disable browser polling for unsaved chat IDs. Resume polling when the chat has a task UUID. - Return the existing task-not-found response for malformed task IDs before database access. Keep board authentication first and company and credential checks unchanged. - Test all six task browser routes, draft-to-saved polling, normal tasks, saved chats, and denied access. Keep canonical UUID v4, v7, and nil values queryable. - Document the draft and saved-chat browser contract. ## Verification - `pnpm install --frozen-lockfile` passed with pnpm 9.15.4 and Node 24.21.0. - `pnpm exec vitest run server/src/__tests__/browser-use-route-scope.test.ts server/src/__tests__/browser-use-connection.test.ts` passed all 35 tests. - `pnpm exec vitest run ui/src/hooks/useTaskBrowsers.test.ts` passed all 5 tests. - Independent review passed 14 hook and route tests plus both real task and saved-chat authorization cases. - `pnpm check:token-gates` and `git diff --check` passed. - Full workspace `pnpm -r typecheck` and `pnpm build` passed. - Full Linux CI passed on `dafff44a32`: 53 successful checks and two intentional Storybook skips. This includes all general and serialized test groups, UI and browser tests, typecheck, build, and the canary dry run. No CI retries were needed. - The full local `TMPDIR=/private/tmp pnpm test:run` started but did not complete. It was stopped after full Linux CI passed. Before the stop, three company-skills cache cases failed on macOS. A focused rerun reproduced all three as `EACCES` while renaming immutable cache staging directories. The test, company-skills service, and runtime cache files are byte-identical to the baseline previously reproduced on `e99854249c` for #15291. Other local groups were not reached; the full Linux CI run provides aggregate coverage. - Greptile scored the exact head `dafff44a32` 5/5 with no findings or unresolved review threads. ## Risks - Malformed task IDs now return 404 instead of reaching PostgreSQL and returning 500. The route does not map draft IDs to agents or tasks. - No schema, browser provider, task creation, or authorization policy changes. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository editing, code execution, and independent agent review. The exact deployment model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub references) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused suites; full local run limitations are reported above) - [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>
1396 lines
53 KiB
TypeScript
1396 lines
53 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.each(["task", "saved chat"])("protects %s browser routes from agent, other-company and read-only access", async (kind) => {
|
|
const f = await fixture();
|
|
if (kind === "saved chat") {
|
|
await db.update(issues).set({
|
|
conversationAgentId: f.agent.id,
|
|
conversationUserId: actor.actorId,
|
|
conversationState: "active",
|
|
}).where(eq(issues.id, f.issue.id));
|
|
}
|
|
await f.service.execute(
|
|
f.binding,
|
|
f.grant,
|
|
randomUUID(),
|
|
"browser_start",
|
|
{ task: "Read" },
|
|
);
|
|
f.ready();
|
|
await f.tick();
|
|
const [browser] = await f.service.list(
|
|
f.company.id,
|
|
f.issue.id,
|
|
actor.actorId,
|
|
);
|
|
let mode = "board";
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor =
|
|
mode === "agent"
|
|
? {
|
|
type: "agent",
|
|
companyId: f.company.id,
|
|
agentId: f.agent.id,
|
|
source: "agent_key",
|
|
}
|
|
: {
|
|
type: "board",
|
|
userId: actor.actorId,
|
|
source: "session",
|
|
companyIds: mode === "other-company" ? [] : [f.company.id],
|
|
};
|
|
next();
|
|
});
|
|
app.use(browserUseRoutes(db, f.service));
|
|
app.use(errorHandler);
|
|
const listPath = `/issues/${f.issue.id}/browsers`;
|
|
const listed = await http(app).get(listPath);
|
|
expect(listed.status).toBe(200);
|
|
expect(listed.body).toMatchObject([{ id: browser.id, issueId: f.issue.id }]);
|
|
const missing = await http(app).get(`/issues/${randomUUID()}/browsers`);
|
|
expect(missing.status).toBe(404);
|
|
expect(missing.body).toEqual({ error: "Task not found" });
|
|
const path = `/issues/${f.issue.id}/browsers/${browser.id}/viewer`;
|
|
const ok = await http(app).get(path);
|
|
expect(ok.status).toBe(200);
|
|
expect(ok.headers["cache-control"]).toBe("no-store");
|
|
expect(ok.headers["referrer-policy"]).toBe("no-referrer");
|
|
const controlPath = path.replace("/viewer", "/control");
|
|
const presencePath = path.replace("/viewer", "/presence");
|
|
expect((await http(app).post(presencePath)).status).toBe(200);
|
|
const resizePath = path.replace("/viewer", "/viewport");
|
|
const resizeSpy = vi
|
|
.spyOn(browserUseViewports, "resize")
|
|
.mockResolvedValue({ preset: "phone" });
|
|
expect(
|
|
(await http(app).post(resizePath).send({ preset: "phone" })).body,
|
|
).toEqual({ preset: "phone" });
|
|
expect(resizeSpy).toHaveBeenCalledTimes(1);
|
|
const viewerId = randomUUID();
|
|
resizeSpy.mockResolvedValueOnce({
|
|
preset: "fit",
|
|
width: 531,
|
|
height: 712,
|
|
applied: true,
|
|
});
|
|
expect(
|
|
(
|
|
await http(app)
|
|
.post(resizePath)
|
|
.send({ preset: "fit", width: 531, height: 712, viewerId })
|
|
).body,
|
|
).toMatchObject({ preset: "fit", width: 531 });
|
|
expect(resizeSpy).toHaveBeenLastCalledWith(
|
|
expect.any(String),
|
|
expect.any(String),
|
|
{ preset: "fit", width: 531, height: 712, viewerId },
|
|
expect.any(String),
|
|
`${actor.actorId}:${viewerId}`,
|
|
);
|
|
expect(
|
|
(await http(app).post(`${resizePath}/release`).send({ viewerId }))
|
|
.status,
|
|
).toBe(200);
|
|
expect(
|
|
(await http(app).post(resizePath).send({ preset: "custom", width: 1 }))
|
|
.status,
|
|
).toBe(400);
|
|
expect((await http(app).post(controlPath).send({ action: "cancel" })).status).toBe(200);
|
|
mode = "agent";
|
|
expect((await http(app).get(listPath)).status).toBe(403);
|
|
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(403);
|
|
expect((await http(app).post(presencePath)).status).toBe(403);
|
|
expect(
|
|
(await http(app).post(`${resizePath}/release`).send({ viewerId }))
|
|
.status,
|
|
).toBe(403);
|
|
expect(
|
|
(await http(app).post(resizePath).send({ preset: "phone" })).status,
|
|
).toBe(403);
|
|
expect((await http(app).get(path)).status).toBe(403);
|
|
mode = "other-company";
|
|
const inaccessible = await http(app).get(listPath);
|
|
expect(inaccessible.status).toBe(missing.status);
|
|
expect(inaccessible.body).toEqual(missing.body);
|
|
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(404);
|
|
expect((await http(app).post(presencePath)).status).toBe(404);
|
|
expect(
|
|
(await http(app).post(`${resizePath}/release`).send({ viewerId }))
|
|
.status,
|
|
).toBe(404);
|
|
expect(
|
|
(await http(app).post(resizePath).send({ preset: "phone" })).status,
|
|
).toBe(404);
|
|
expect((await http(app).get(path)).status).toBe(404);
|
|
mode = "board";
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ membershipRole: "viewer" })
|
|
.where(eq(companyMemberships.companyId, f.company.id));
|
|
expect((await http(app).get(path)).status).toBe(403);
|
|
expect((await http(app).get(listPath)).body).toEqual([]);
|
|
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(403);
|
|
expect((await http(app).post(presencePath)).status).toBe(403);
|
|
expect(
|
|
(await http(app).post(resizePath).send({ preset: "phone" })).status,
|
|
).toBe(403);
|
|
expect(resizeSpy).toHaveBeenCalledTimes(2);
|
|
resizeSpy.mockRestore();
|
|
});
|
|
it("rechecks current gateway access and closes browsers when it is removed", async () => {
|
|
const f = await fixture();
|
|
const start = (
|
|
await f.gateway.listToolsForSession(f.gatewaySession.token)
|
|
).find((t) => t.upstreamToolName === "browser_start")!;
|
|
await f.gateway.executeTool({
|
|
sessionToken: f.gatewaySession.token,
|
|
tool: start.name,
|
|
parameters: { task: "Read" },
|
|
idempotencyKey: randomUUID(),
|
|
});
|
|
f.ready();
|
|
await f.tick();
|
|
const [run] = await db
|
|
.select()
|
|
.from(browserUseRuns)
|
|
.where(eq(browserUseRuns.companyId, f.company.id));
|
|
const scope = {
|
|
...f.binding,
|
|
runId: run.heartbeatRunId,
|
|
connectionId: f.grant.connectionId,
|
|
grantId: f.grant.id,
|
|
invocationId: run.invocationId,
|
|
};
|
|
expect(await f.gateway.browserUseSessionAuthorized(scope)).toBe(true);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: f.company.id,
|
|
name: "Disable browser access",
|
|
policyType: "block",
|
|
selectors: { connectionId: f.connection.connectionId },
|
|
priority: 100,
|
|
});
|
|
expect(await f.gateway.browserUseSessionAuthorized(scope)).toBe(false);
|
|
await db
|
|
.update(browserUseSessions)
|
|
.set({ nextPollAt: new Date(0) })
|
|
.where(eq(browserUseSessions.companyId, f.company.id));
|
|
await browserUseService(db, f.request, {}, () =>
|
|
f.gateway.browserUseSessionAuthorized(scope),
|
|
).sweep();
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(browserUseSessions)
|
|
.where(eq(browserUseSessions.id, run.sessionId))
|
|
)[0].status,
|
|
).toBe("closed");
|
|
});
|
|
|
|
it("resolves only the selected personal credential and denies another member's viewer", async () => {
|
|
const f = await fixture(true);
|
|
expect(f.grant.kind).toBe("user");
|
|
const tool = (
|
|
await f.gateway.listToolsForSession(f.gatewaySession.token)
|
|
).find((t) => t.upstreamToolName === "browser_start")!;
|
|
await f.gateway.executeTool({
|
|
sessionToken: f.gatewaySession.token,
|
|
tool: tool.name,
|
|
parameters: { task: "Read" },
|
|
idempotencyKey: randomUUID(),
|
|
});
|
|
f.ready();
|
|
await f.tick();
|
|
const [browser] = await f.service.list(
|
|
f.company.id,
|
|
f.issue.id,
|
|
actor.actorId,
|
|
);
|
|
expect(
|
|
await f.service.viewer(
|
|
f.company.id,
|
|
f.issue.id,
|
|
browser.id,
|
|
actor.actorId,
|
|
),
|
|
).toHaveProperty("url");
|
|
await db.insert(companyMemberships).values({
|
|
companyId: f.company.id,
|
|
principalType: "user",
|
|
principalId: "other-reviewer",
|
|
membershipRole: "admin",
|
|
status: "active",
|
|
});
|
|
await expect(
|
|
f.service.viewer(
|
|
f.company.id,
|
|
f.issue.id,
|
|
browser.id,
|
|
"other-reviewer",
|
|
),
|
|
).rejects.toThrow("another credential");
|
|
await f.access.archiveConnection(
|
|
f.connection.connectionId,
|
|
f.company.id,
|
|
actor,
|
|
);
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(browserUseSessions)
|
|
.where(eq(browserUseSessions.companyId, f.company.id))
|
|
)[0].status,
|
|
).toBe("closed");
|
|
});
|
|
it("continues an idle conversation and bounds Keep open with idle cleanup", async () => {
|
|
const f = await fixture();
|
|
const start = (await f.service.execute(
|
|
f.binding,
|
|
f.grant,
|
|
randomUUID(),
|
|
"browser_start",
|
|
{ task: "Read" },
|
|
)) as { sessionId: string };
|
|
f.ready();
|
|
f.complete();
|
|
await f.tick();
|
|
const [idle] = await db
|
|
.select()
|
|
.from(browserUseSessions)
|
|
.where(eq(browserUseSessions.id, start.sessionId));
|
|
await f.service.control(idle, "keep_open");
|
|
await f.service.execute(
|
|
f.binding,
|
|
f.grant,
|
|
randomUUID(),
|
|
"browser_continue",
|
|
{ sessionId: start.sessionId, task: "Follow up" },
|
|
);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(browserUseRuns)
|
|
.where(eq(browserUseRuns.sessionId, start.sessionId)),
|
|
).toHaveLength(2);
|
|
const calls = f.request.mock.calls.filter(
|
|
([url, init]) => url.endsWith("/runs") && init.method === "POST",
|
|
);
|
|
expect(JSON.parse(String(calls[1][1].body)).sessionId).toBe(
|
|
idle.providerSessionId,
|
|
);
|
|
f.complete();
|
|
await f.tick();
|
|
await db
|
|
.update(browserUseSessions)
|
|
.set({ idleDeadline: new Date(0) })
|
|
.where(eq(browserUseSessions.id, start.sessionId));
|
|
await f.tick();
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(browserUseSessions)
|
|
.where(eq(browserUseSessions.id, start.sessionId))
|
|
)[0].status,
|
|
).toBe("closed");
|
|
});
|
|
it("caps paid work at the remaining hard budget and enforces reported spend", async () => {
|
|
const f = await fixture();
|
|
await budgetService(db).upsertPolicy(
|
|
f.company.id,
|
|
{
|
|
scopeType: "agent",
|
|
scopeId: f.agent.id,
|
|
amount: 15,
|
|
hardStopEnabled: true,
|
|
},
|
|
actor.actorId,
|
|
);
|
|
await f.service.saveSettings(f.company.id, f.grant.id, actor.actorId, {
|
|
allowedProfileIds: [],
|
|
maxCostUsd: 0.5,
|
|
});
|
|
await f.service.execute(
|
|
f.binding,
|
|
f.grant,
|
|
randomUUID(),
|
|
"browser_start",
|
|
{ task: "Read", maxCostUsd: 1 },
|
|
);
|
|
const call = f.request.mock.calls.find(
|
|
([url, init]) => url.endsWith("/runs") && init.method === "POST",
|
|
)!;
|
|
expect(JSON.parse(String(call[1].body))).toMatchObject({
|
|
maxCostUsd: 0.15,
|
|
browserSettings: { record: false },
|
|
});
|
|
f.ready();
|
|
f.complete();
|
|
await f.tick();
|
|
await f.tick();
|
|
expect(
|
|
(await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0],
|
|
).toMatchObject({ status: "paused", spentMonthlyCents: 15 });
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(browserUseSessions)
|
|
.where(eq(browserUseSessions.companyId, f.company.id))
|
|
)[0].status,
|
|
).toBe("closed");
|
|
await expect(
|
|
f.service.execute(f.binding, f.grant, randomUUID(), "browser_start", {
|
|
task: "Read again",
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
it("preserves the idle grace period when the task and parent finish before the last poll", async () => {
|
|
const f = await fixture();
|
|
await f.service.execute(
|
|
f.binding,
|
|
f.grant,
|
|
randomUUID(),
|
|
"browser_start",
|
|
{ task: "Read" },
|
|
);
|
|
f.ready();
|
|
f.complete();
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ status: "succeeded" })
|
|
.where(eq(heartbeatRuns.id, f.run.id));
|
|
await db
|
|
.update(issues)
|
|
.set({ status: "done" })
|
|
.where(eq(issues.id, f.issue.id));
|
|
await f.tick();
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(browserUseSessions)
|
|
.where(eq(browserUseSessions.companyId, f.company.id))
|
|
)[0].status,
|
|
).toBe("idle");
|
|
expect(
|
|
f.request.mock.calls.some(([url]) => url.endsWith("/cancel")),
|
|
).toBe(false);
|
|
});
|
|
it("confirms hosted shutdown before removing the connection credential", async () => {
|
|
const f = await fixture();
|
|
await f.service.execute(
|
|
f.binding,
|
|
f.grant,
|
|
randomUUID(),
|
|
"browser_start",
|
|
{ task: "Read" },
|
|
);
|
|
f.ready();
|
|
await f.tick();
|
|
await f.access.archiveConnection(
|
|
f.connection.connectionId,
|
|
f.company.id,
|
|
actor,
|
|
);
|
|
const [session] = await db
|
|
.select()
|
|
.from(browserUseSessions)
|
|
.where(eq(browserUseSessions.companyId, f.company.id));
|
|
expect(session.status).toBe("closed");
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, f.grant.id));
|
|
expect(grant.status).toBe("revoked");
|
|
expect(grant.credentialSecretRefs).toEqual([]);
|
|
});
|
|
},
|
|
);
|