@anybrowser/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +78 -2
- package/dist/allowedSites.js +164 -0
- package/dist/allowedSites.js.map +1 -0
- package/dist/blockLedger.js +111 -0
- package/dist/blockLedger.js.map +1 -0
- package/dist/browser.js +1096 -0
- package/dist/browser.js.map +1 -0
- package/dist/browserDetect.js +53 -0
- package/dist/browserDetect.js.map +1 -0
- package/dist/cli.js +366 -0
- package/dist/cli.js.map +1 -0
- package/dist/connect.js +195 -0
- package/dist/connect.js.map +1 -0
- package/dist/daemon.js +475 -0
- package/dist/daemon.js.map +1 -0
- package/dist/hello.js +62 -0
- package/dist/hello.js.map +1 -0
- package/dist/idle.js +84 -0
- package/dist/idle.js.map +1 -0
- package/dist/install.js +63 -0
- package/dist/install.js.map +1 -0
- package/dist/paths.js +36 -0
- package/dist/paths.js.map +1 -0
- package/dist/profile.js +273 -0
- package/dist/profile.js.map +1 -0
- package/dist/proxy.js +40 -0
- package/dist/proxy.js.map +1 -0
- package/dist/siteProxy.js +354 -0
- package/dist/siteProxy.js.map +1 -0
- package/dist/transport.js +85 -0
- package/dist/transport.js.map +1 -0
- package/dist/version.js +12 -0
- package/dist/version.js.map +1 -0
- package/package.json +50 -4
- package/skills/anybrowser/SKILL.md +42 -0
package/dist/browser.js
ADDED
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
import { mkdirSync, statSync } from "node:fs";
|
|
3
|
+
import { createConnection } from "@playwright/mcp";
|
|
4
|
+
import { chromium, } from "playwright";
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
7
|
+
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import { isUrlAllowed } from "./allowedSites.js";
|
|
9
|
+
import { BlockLedger } from "./blockLedger.js";
|
|
10
|
+
import { allowedOriginsProblem, profileExists, profileJsonPath, readProfile, } from "./profile.js";
|
|
11
|
+
import { escapeHtml, SiteProxy } from "./siteProxy.js";
|
|
12
|
+
/**
|
|
13
|
+
* A reserved key used only to fetch the upstream tool schema (never a real
|
|
14
|
+
* profile id: real ids always contain "-in-", which this key does not, so it
|
|
15
|
+
* can never collide with one). Listing tools never touches the router's
|
|
16
|
+
* `contextGetter`, so this never launches a browser or requires any profile
|
|
17
|
+
* to exist.
|
|
18
|
+
*/
|
|
19
|
+
const TOOLS_SCHEMA_KEY = "__anybrowser_tools_schema__";
|
|
20
|
+
/**
|
|
21
|
+
* Tools the daemon removes from upstream's list: the daemon owns browser
|
|
22
|
+
* lifetime (decision 14), so `browser_close` is dropped (and would refuse
|
|
23
|
+
* anyway under `sharedBrowserContext: true`), and `browser_install` is
|
|
24
|
+
* dropped because the launcher handles downloads itself.
|
|
25
|
+
*/
|
|
26
|
+
const REMOVED_TOOLS = new Set(["browser_close", "browser_install"]);
|
|
27
|
+
const PROFILE_PROPERTY = {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "The anybrowser profile to run this tool against.",
|
|
30
|
+
};
|
|
31
|
+
/** Reads whether the browser should launch headless. Unset/anything else means headed. */
|
|
32
|
+
export function isHeadless(env = process.env) {
|
|
33
|
+
const raw = env["ANYBROWSER_HEADLESS"];
|
|
34
|
+
return raw === "1" || raw === "true";
|
|
35
|
+
}
|
|
36
|
+
/** Adds a required string `profile` property to a plain-JSON-Schema tool input schema. */
|
|
37
|
+
function addProfileProperty(tool) {
|
|
38
|
+
const schema = tool.inputSchema;
|
|
39
|
+
const properties = {
|
|
40
|
+
...(schema.properties ?? {}),
|
|
41
|
+
profile: PROFILE_PROPERTY,
|
|
42
|
+
};
|
|
43
|
+
const required = [...(schema.required ?? []), "profile"];
|
|
44
|
+
return {
|
|
45
|
+
...tool,
|
|
46
|
+
inputSchema: {
|
|
47
|
+
...schema,
|
|
48
|
+
type: "object",
|
|
49
|
+
properties,
|
|
50
|
+
required,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Matches a "[Snapshot](path)" markdown link, as written by upstream's Response.serialize(). */
|
|
55
|
+
const SNAPSHOT_LINK_PATTERN = /\[Snapshot\]\(([^)]+)\)/g;
|
|
56
|
+
/**
|
|
57
|
+
* Upstream (`@playwright/mcp` 0.0.81) always writes a snapshot produced by
|
|
58
|
+
* `browser_navigate` and friends (any tool using `response.setIncludeSnapshot()`,
|
|
59
|
+
* as opposed to the dedicated `browser_snapshot` tool) to a FILE and returns
|
|
60
|
+
* a markdown link, never inline: `Response._includeSnapshot` is `"full"`
|
|
61
|
+
* there (never `"explicit"`), so `Response._build()`'s `snapshotToFile` is
|
|
62
|
+
* always true and there is no config knob (`snapshot.mode` is only
|
|
63
|
+
* `"full" | "none"`) that keeps it inline. That link is written relative to
|
|
64
|
+
* `Response._clientWorkspace`, which defaults to `context.options.cwd` -
|
|
65
|
+
* `firstRootPath(clientRoots)` in upstream's `initializeServer`, i.e.
|
|
66
|
+
* `process.cwd()` when the MCP client declares no `roots` capability, which
|
|
67
|
+
* our embedded client (below) does not. So the link is always relative to
|
|
68
|
+
* the DAEMON's cwd, not the outputDir the file actually lives in, and
|
|
69
|
+
* `path.relative` never produces an absolute result on POSIX - there is no
|
|
70
|
+
* config that fixes this for the general (non-`browser_snapshot`) tool path.
|
|
71
|
+
* Cheapest correct fix: resolve the relative link (same `process.cwd()` base
|
|
72
|
+
* upstream used) to an absolute path before it reaches the agent host.
|
|
73
|
+
*/
|
|
74
|
+
function absolutizeSnapshotLinks(content) {
|
|
75
|
+
return content.map((item) => {
|
|
76
|
+
if (typeof item !== "object" ||
|
|
77
|
+
item === null ||
|
|
78
|
+
item.type !== "text" ||
|
|
79
|
+
typeof item.text !== "string") {
|
|
80
|
+
return item;
|
|
81
|
+
}
|
|
82
|
+
const text = item.text;
|
|
83
|
+
const rewritten = text.replace(SNAPSHOT_LINK_PATTERN, (match, link) => isAbsolute(link)
|
|
84
|
+
? match
|
|
85
|
+
: `[Snapshot](${resolve(process.cwd(), link)})`);
|
|
86
|
+
return rewritten === text ? item : { ...item, text: rewritten };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** How long a refused navigation is given to land on Chromium's error page before the caller moves on. */
|
|
90
|
+
const BLOCKED_NAVIGATION_SETTLE_MS = 2000;
|
|
91
|
+
/** How long a tool call waits for Playwright's events to say which request the proxy just refused. */
|
|
92
|
+
const CLAIM_WAIT_MS = 1000;
|
|
93
|
+
/** The longest a cached allowed-sites list is trusted without re-reading profile.json. */
|
|
94
|
+
const ALLOWED_CACHE_MAX_AGE_MS = 1000;
|
|
95
|
+
/** The message for an agent whose request was refused. It never says how to change the list. */
|
|
96
|
+
function blockedMessage(profile, allowedOrigins, url) {
|
|
97
|
+
return (`Blocked: ${url} is not allowed. ` +
|
|
98
|
+
`Profile '${profile}' is restricted to an allowed-sites list (${allowedOrigins.join(", ")}). ` +
|
|
99
|
+
`The user sets that list outside the agent; do not try to change it or get around it. ` +
|
|
100
|
+
`If this site is needed, tell the user.`);
|
|
101
|
+
}
|
|
102
|
+
/** The most hosts a subresource note names before it counts the rest. */
|
|
103
|
+
const MAX_NOTED_HOSTS = 5;
|
|
104
|
+
/**
|
|
105
|
+
* The note for a page that loaded while requests it made were refused. It
|
|
106
|
+
* names distinct hosts (with the port when it is not the scheme's default,
|
|
107
|
+
* since the list needs it written out), sorted so the text is stable, and
|
|
108
|
+
* never says how to change the list.
|
|
109
|
+
*/
|
|
110
|
+
function incompletePageMessage(profile, allowedOrigins, urls) {
|
|
111
|
+
const hosts = new Set();
|
|
112
|
+
for (const url of urls) {
|
|
113
|
+
try {
|
|
114
|
+
hosts.add(new URL(url).host);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Not a URL; there is no host to name.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const sorted = [...hosts].sort();
|
|
121
|
+
const shown = sorted.slice(0, MAX_NOTED_HOSTS).join(", ");
|
|
122
|
+
const named = sorted.length > MAX_NOTED_HOSTS
|
|
123
|
+
? `${shown} and ${sorted.length - MAX_NOTED_HOSTS} more`
|
|
124
|
+
: shown;
|
|
125
|
+
return (`The page loaded but may be incomplete: requests it made to ${named} were refused. ` +
|
|
126
|
+
`Profile '${profile}' is restricted to an allowed-sites list (${allowedOrigins.join(", ")}), and these hosts would need to be on it. ` +
|
|
127
|
+
`Only the user can change that list; do not try to change it or get around it. ` +
|
|
128
|
+
`If the page needs them, tell the user.`);
|
|
129
|
+
}
|
|
130
|
+
/** `url` without its query and fragment, for the log: those are where tokens live. */
|
|
131
|
+
function loggableUrl(url) {
|
|
132
|
+
try {
|
|
133
|
+
const parsed = new URL(url);
|
|
134
|
+
return `${parsed.origin === "null" ? parsed.protocol : parsed.origin}${parsed.pathname}`;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return "(unparseable)";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Whether `page` was opened, directly or through other pages, by `ancestor` (a popup of it). */
|
|
141
|
+
async function openedBy(page, ancestor) {
|
|
142
|
+
let current = page;
|
|
143
|
+
for (let depth = 0; depth < 5; depth++) {
|
|
144
|
+
const opener = await current.opener().catch(() => null);
|
|
145
|
+
if (opener === null) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (opener === ancestor) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
current = opener;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
/** Up to five distinct URLs, then a count of the rest, for one message. */
|
|
156
|
+
function describeUrls(blocks) {
|
|
157
|
+
const urls = [...new Set(blocks.map((b) => b.url))];
|
|
158
|
+
const shown = urls.slice(0, 5).join(", ");
|
|
159
|
+
return urls.length > 5 ? `${shown} and ${urls.length - 5} more` : shown;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Owns the one persistent Chromium context per profile, launched lazily on
|
|
163
|
+
* the first browser tool CALL (never on daemon start, never on a bare
|
|
164
|
+
* `tools/list`). A per-profile "launching" promise makes concurrent first
|
|
165
|
+
* calls share one launch instead of racing (decision 10).
|
|
166
|
+
*/
|
|
167
|
+
/** Thrown by `BrowserContextRouter.loginLaunch` when a profile is already running headless without the global override. */
|
|
168
|
+
export class ProfileHeadlessRunningError extends Error {
|
|
169
|
+
}
|
|
170
|
+
/** Thrown by `BrowserContextRouter.loginLaunch` when the allowed-sites list refuses the URL; the message is safe to show the agent. */
|
|
171
|
+
export class ProfileUrlBlockedError extends Error {
|
|
172
|
+
}
|
|
173
|
+
/** Per-context close timeout shared by `closeAll` and a single profile's idle-close. */
|
|
174
|
+
const PER_CONTEXT_CLOSE_TIMEOUT_MS = 3000;
|
|
175
|
+
export class BrowserContextRouter {
|
|
176
|
+
paths;
|
|
177
|
+
log;
|
|
178
|
+
idleMs;
|
|
179
|
+
contexts = new Map();
|
|
180
|
+
/** Profiles whose launch has SUCCEEDED (a resolved context), for profile_status/profile_list/daemon_status. */
|
|
181
|
+
runningContexts = new Map();
|
|
182
|
+
/** Whether each running profile's browser is headless, for profile_login's headless-relaunch guard. */
|
|
183
|
+
runningHeadless = new Map();
|
|
184
|
+
/** Per-profile "browser idle close" timer (decision 5), armed by `endActivity`. */
|
|
185
|
+
idleTimers = new Map();
|
|
186
|
+
/** Timestamp the last tool call naming each running profile FINISHED, for `idleForMs`. */
|
|
187
|
+
lastActivity = new Map();
|
|
188
|
+
/**
|
|
189
|
+
* Count of in-flight tool calls naming each profile. While this is above
|
|
190
|
+
* zero the idle-close timer is held off entirely, so a single slow call
|
|
191
|
+
* (e.g. a slow page load) can never be mistaken for idleness and close
|
|
192
|
+
* the browser out from under it - only cleared, silent time between calls
|
|
193
|
+
* counts towards the idle window.
|
|
194
|
+
*/
|
|
195
|
+
activeCalls = new Map();
|
|
196
|
+
/**
|
|
197
|
+
* Bumped every time a profile's context is closed (idle-close, `closeAll`,
|
|
198
|
+
* or the browser going away on its own), so a `BrowserSession`'s cached
|
|
199
|
+
* embedded MCP connection for that profile (which is keyed on this
|
|
200
|
+
* generation) is dropped and recreated on the next call instead of reusing
|
|
201
|
+
* a connection bound to a now-closed context.
|
|
202
|
+
*/
|
|
203
|
+
generations = new Map();
|
|
204
|
+
/** The last allowed-sites list read for each profile, used if profile.json is unreadable mid-edit. */
|
|
205
|
+
lastKnownAllowed = new Map();
|
|
206
|
+
/** The profile.json stamp and result of the last list read, so a request does not re-parse the file. */
|
|
207
|
+
allowedCache = new Map();
|
|
208
|
+
/** The running profile's proxy listener (see src/siteProxy.ts). Every profile has one, pinned or not. */
|
|
209
|
+
proxies = new Map();
|
|
210
|
+
/**
|
|
211
|
+
* Every listener port ever opened, kept for the daemon's life: a proxy
|
|
212
|
+
* never dials another proxy, whichever profile it belongs to.
|
|
213
|
+
*/
|
|
214
|
+
proxyPorts = new Set();
|
|
215
|
+
/** Listeners whose browser may still be alive (its close failed or timed out), kept so the port is never reused under it. */
|
|
216
|
+
retiredProxies = [];
|
|
217
|
+
/** What each profile's proxy refused, claimed by Playwright events (see src/blockLedger.ts). */
|
|
218
|
+
ledgers = new Map();
|
|
219
|
+
/** Each running profile's first page, which no session owns and none may close. */
|
|
220
|
+
firstPages = new Map();
|
|
221
|
+
/** When each frame last committed a navigation, so a refused navigation can tell it has been replaced. */
|
|
222
|
+
commitTimes = new WeakMap();
|
|
223
|
+
constructor(paths, log,
|
|
224
|
+
/** Resolved once at daemon startup (src/idle.ts); null disables per-profile idle close. */
|
|
225
|
+
idleMs) {
|
|
226
|
+
this.paths = paths;
|
|
227
|
+
this.log = log;
|
|
228
|
+
this.idleMs = idleMs;
|
|
229
|
+
}
|
|
230
|
+
/** Current generation number for `profile` (see `generations` above). Defaults to 0. */
|
|
231
|
+
generationOf(profile) {
|
|
232
|
+
return this.generations.get(profile) ?? 0;
|
|
233
|
+
}
|
|
234
|
+
bumpGeneration(profile) {
|
|
235
|
+
this.generations.set(profile, this.generationOf(profile) + 1);
|
|
236
|
+
}
|
|
237
|
+
/** Milliseconds since the last tool call naming `profile`, or undefined if it isn't running. */
|
|
238
|
+
idleForMs(profile) {
|
|
239
|
+
const last = this.lastActivity.get(profile);
|
|
240
|
+
return last === undefined ? undefined : Date.now() - last;
|
|
241
|
+
}
|
|
242
|
+
clearIdleTimer(profile) {
|
|
243
|
+
const timer = this.idleTimers.get(profile);
|
|
244
|
+
if (timer !== undefined) {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
this.idleTimers.delete(profile);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** (Re-)arms the idle-close timer for `profile`, cancelling any previous one. A no-op when idling is disabled. */
|
|
250
|
+
armIdleTimer(profile) {
|
|
251
|
+
this.clearIdleTimer(profile);
|
|
252
|
+
if (this.idleMs === null) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const timer = setTimeout(() => {
|
|
256
|
+
void this.closeIdleProfile(profile);
|
|
257
|
+
}, this.idleMs);
|
|
258
|
+
this.idleTimers.set(profile, timer);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Marks the start of one tool call naming `profile`: holds off the
|
|
262
|
+
* idle-close timer until every in-flight call for this profile has
|
|
263
|
+
* finished (see `activeCalls`'s doc comment). Call on every browser tool
|
|
264
|
+
* call and on `profile_login`, paired with `endActivity`.
|
|
265
|
+
*/
|
|
266
|
+
beginActivity(profile) {
|
|
267
|
+
this.clearIdleTimer(profile);
|
|
268
|
+
this.activeCalls.set(profile, (this.activeCalls.get(profile) ?? 0) + 1);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Marks the end of one tool call naming `profile`. Once the last
|
|
272
|
+
* concurrent call for this profile finishes, records the current time as
|
|
273
|
+
* its last activity and (re)arms the idle-close timer counting forward
|
|
274
|
+
* from now - never from before the call started.
|
|
275
|
+
*/
|
|
276
|
+
endActivity(profile) {
|
|
277
|
+
const remaining = Math.max(0, (this.activeCalls.get(profile) ?? 1) - 1);
|
|
278
|
+
if (remaining > 0) {
|
|
279
|
+
this.activeCalls.set(profile, remaining);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.activeCalls.delete(profile);
|
|
283
|
+
this.lastActivity.set(profile, Date.now());
|
|
284
|
+
this.armIdleTimer(profile);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Closes one profile's browser because it has been idle for `idleMs` with
|
|
288
|
+
* no tool call naming it (decision 5), reusing the same graceful close
|
|
289
|
+
* path as `closeAll` (so the cookie store is flushed - a profile that is
|
|
290
|
+
* idle-closed must keep its logins). The profile relaunches lazily on its
|
|
291
|
+
* next tool call, exactly like a profile that has never been launched.
|
|
292
|
+
*
|
|
293
|
+
* Evicts the profile from the launch cache BEFORE awaiting the close, so
|
|
294
|
+
* a tool call racing this idle-close triggers a fresh launch rather than
|
|
295
|
+
* reusing a context that is mid-close.
|
|
296
|
+
*/
|
|
297
|
+
async closeIdleProfile(profile) {
|
|
298
|
+
const context = this.runningContexts.get(profile);
|
|
299
|
+
if (!context) {
|
|
300
|
+
return; // already closed by other means (e.g. daemon shutdown)
|
|
301
|
+
}
|
|
302
|
+
this.activeCalls.delete(profile);
|
|
303
|
+
const proxy = this.forgetRunning(profile);
|
|
304
|
+
const closed = await this.closeOneContext(profile, context, PER_CONTEXT_CLOSE_TIMEOUT_MS);
|
|
305
|
+
await this.releaseProxy(profile, proxy, closed);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* A profile's browser closed without the daemon closing it: the user quit
|
|
309
|
+
* it, it crashed, or it was killed. The daemon's own closes evict the
|
|
310
|
+
* profile before closing, and a newer launch replaces the cached context,
|
|
311
|
+
* so a close event for anything but the cached context is ignored.
|
|
312
|
+
*/
|
|
313
|
+
onContextClosed(profile, context) {
|
|
314
|
+
if (this.runningContexts.get(profile) !== context) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.log(`browser closed unexpectedly: profile=${profile}`);
|
|
318
|
+
void this.releaseProxy(profile, this.forgetRunning(profile), true);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Drops what the router keeps for `profile`'s running browser, so the next
|
|
322
|
+
* tool call naming it launches a fresh one and every session reconnects
|
|
323
|
+
* (the generation bump). Returns the profile's proxy, for the caller to
|
|
324
|
+
* release once it knows whether the browser is gone. In-flight call counts
|
|
325
|
+
* are left alone: those calls still end and balance them.
|
|
326
|
+
*/
|
|
327
|
+
forgetRunning(profile) {
|
|
328
|
+
this.clearIdleTimer(profile);
|
|
329
|
+
this.lastActivity.delete(profile);
|
|
330
|
+
this.contexts.delete(profile);
|
|
331
|
+
this.runningContexts.delete(profile);
|
|
332
|
+
this.runningHeadless.delete(profile);
|
|
333
|
+
this.firstPages.delete(profile);
|
|
334
|
+
this.bumpGeneration(profile);
|
|
335
|
+
const proxy = this.proxies.get(profile);
|
|
336
|
+
this.proxies.delete(profile);
|
|
337
|
+
return proxy;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Closes a profile's listener, but only once its browser is known to be
|
|
341
|
+
* gone: a port released while a browser launched against it may still live
|
|
342
|
+
* could be bound by a different listener later, and the live browser would
|
|
343
|
+
* use that one with no relaunch. An uncertain close keeps the listener.
|
|
344
|
+
*/
|
|
345
|
+
async releaseProxy(profile, proxy, browserClosed) {
|
|
346
|
+
if (!proxy) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (browserClosed) {
|
|
350
|
+
await proxy.close();
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
this.retiredProxies.push(proxy);
|
|
354
|
+
this.log(`proxy kept: profile=${profile} port=${proxy.port} reason=browser close not confirmed`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Closes one browser context, bounded by `timeoutMs`, logging exactly one
|
|
359
|
+
* of `browser close: profile=...`, `browser close timed out: profile=...`
|
|
360
|
+
* or `browser close failed: profile=... error=...`. Shared by `closeAll`
|
|
361
|
+
* and `closeIdleProfile` so both reuse the identical graceful-close
|
|
362
|
+
* behaviour (decision 2: a profile must keep its logins across either
|
|
363
|
+
* path).
|
|
364
|
+
*/
|
|
365
|
+
async closeOneContext(profile, context, timeoutMs) {
|
|
366
|
+
let timedOut = false;
|
|
367
|
+
let closed = false;
|
|
368
|
+
const closePromise = context.close();
|
|
369
|
+
const timer = new Promise((resolve) => {
|
|
370
|
+
setTimeout(() => {
|
|
371
|
+
timedOut = true;
|
|
372
|
+
resolve();
|
|
373
|
+
}, timeoutMs).unref();
|
|
374
|
+
});
|
|
375
|
+
try {
|
|
376
|
+
await Promise.race([closePromise, timer]);
|
|
377
|
+
if (timedOut) {
|
|
378
|
+
this.log(`browser close timed out: profile=${profile}`);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
closed = true;
|
|
382
|
+
this.log(`browser close: profile=${profile}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
this.log(`browser close failed: profile=${profile} error=${error instanceof Error ? error.message : String(error)}`);
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
// In the timeout case closePromise is still pending: attach a no-op
|
|
390
|
+
// catch so a late rejection never surfaces as an unhandled rejection.
|
|
391
|
+
closePromise.catch(() => { });
|
|
392
|
+
}
|
|
393
|
+
return closed;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Returns the shared persistent context for `profile`, launching it on
|
|
397
|
+
* first use. A launch that fails is logged and evicted from the cache
|
|
398
|
+
* (instead of being cached forever), so the next call retries rather than
|
|
399
|
+
* replaying the same error until the daemon restarts.
|
|
400
|
+
*
|
|
401
|
+
* `headlessOverride`, when given, is used INSTEAD of the profile's own
|
|
402
|
+
* `headless` flag for a first launch (used by `loginLaunch`, decision 12:
|
|
403
|
+
* a human must see the login window). Ignored once the profile is already
|
|
404
|
+
* running.
|
|
405
|
+
*
|
|
406
|
+
* A browser that goes away on its own is evicted too (`onContextClosed`),
|
|
407
|
+
* so the next call launches a fresh one instead of reusing a dead context.
|
|
408
|
+
*/
|
|
409
|
+
getContext(profile, headlessOverride) {
|
|
410
|
+
let launching = this.contexts.get(profile);
|
|
411
|
+
if (!launching) {
|
|
412
|
+
launching = this.launch(profile, headlessOverride)
|
|
413
|
+
.then((context) => {
|
|
414
|
+
this.runningContexts.set(profile, context);
|
|
415
|
+
context.once("close", () => this.onContextClosed(profile, context));
|
|
416
|
+
return context;
|
|
417
|
+
})
|
|
418
|
+
.catch((error) => {
|
|
419
|
+
this.contexts.delete(profile);
|
|
420
|
+
this.log(`browser launch failed: profile=${profile} error=${error instanceof Error ? error.message : String(error)}`);
|
|
421
|
+
throw error;
|
|
422
|
+
});
|
|
423
|
+
this.contexts.set(profile, launching);
|
|
424
|
+
}
|
|
425
|
+
return launching;
|
|
426
|
+
}
|
|
427
|
+
ledgerFor(profile) {
|
|
428
|
+
let ledger = this.ledgers.get(profile);
|
|
429
|
+
if (!ledger) {
|
|
430
|
+
ledger = new BlockLedger();
|
|
431
|
+
this.ledgers.set(profile, ledger);
|
|
432
|
+
}
|
|
433
|
+
return ledger;
|
|
434
|
+
}
|
|
435
|
+
/** A cursor for `blocksSince`: everything refused for `profile` after this call is "new". */
|
|
436
|
+
blockMark(profile) {
|
|
437
|
+
return this.ledgerFor(profile).mark();
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* What `profile`'s proxy refused after `mark` and a Playwright event has
|
|
441
|
+
* attributed to a URL and a page, one per refusal (a caller dedupes by URL for display). Waits a moment for
|
|
442
|
+
* events that trail the refusal; a refusal no event ever claims (a blocked
|
|
443
|
+
* WebSocket, say) is in the log but cannot be attributed, so it is left out.
|
|
444
|
+
*/
|
|
445
|
+
async blocksSince(profile, mark) {
|
|
446
|
+
const ledger = this.ledgerFor(profile);
|
|
447
|
+
await ledger.whenClaimed(mark, CLAIM_WAIT_MS);
|
|
448
|
+
const blocks = [];
|
|
449
|
+
for (const entry of ledger.since(mark)) {
|
|
450
|
+
if (entry.claim) {
|
|
451
|
+
blocks.push({ ...entry.claim, seq: entry.seq });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return blocks;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* The profile's allowed-sites list as stored right now (null or empty: no
|
|
458
|
+
* restriction). Every request the browser makes asks, so the answer is
|
|
459
|
+
* cached against profile.json's modification stamp (and re-read at least
|
|
460
|
+
* once a second): an edit still takes effect on the next request. While
|
|
461
|
+
* profile.json is unreadable (a write in progress), the last list read
|
|
462
|
+
* stays in force rather than the profile briefly becoming open.
|
|
463
|
+
*/
|
|
464
|
+
currentAllowedOrigins(profile) {
|
|
465
|
+
let stamp = "";
|
|
466
|
+
try {
|
|
467
|
+
const stat = statSync(profileJsonPath(this.paths, profile));
|
|
468
|
+
stamp = `${stat.mtimeMs}:${stat.size}`;
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// Unreadable below as well; the last good list stays.
|
|
472
|
+
}
|
|
473
|
+
const now = Date.now();
|
|
474
|
+
const cached = this.allowedCache.get(profile);
|
|
475
|
+
if (cached &&
|
|
476
|
+
stamp !== "" &&
|
|
477
|
+
cached.stamp === stamp &&
|
|
478
|
+
now - cached.at < ALLOWED_CACHE_MAX_AGE_MS) {
|
|
479
|
+
return cached.list;
|
|
480
|
+
}
|
|
481
|
+
const stored = readProfile(this.paths, profile);
|
|
482
|
+
if (stored) {
|
|
483
|
+
this.lastKnownAllowed.set(profile, stored.allowedOrigins);
|
|
484
|
+
this.allowedCache.set(profile, {
|
|
485
|
+
stamp,
|
|
486
|
+
list: stored.allowedOrigins,
|
|
487
|
+
at: now,
|
|
488
|
+
});
|
|
489
|
+
return stored.allowedOrigins;
|
|
490
|
+
}
|
|
491
|
+
return this.lastKnownAllowed.get(profile) ?? null;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* A refusal message for a `browser_navigate` URL whose scheme never crosses
|
|
495
|
+
* the proxy (`file:`, `javascript:`, ...), or null when the proxy is the
|
|
496
|
+
* right judge of it. Only a pinned profile refuses; `http` and `https` are
|
|
497
|
+
* always left to the proxy.
|
|
498
|
+
*/
|
|
499
|
+
refuseNonNetworkUrl(profile, url) {
|
|
500
|
+
const list = this.currentAllowedOrigins(profile);
|
|
501
|
+
if (!list?.length) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
let protocol;
|
|
505
|
+
try {
|
|
506
|
+
protocol = new URL(url).protocol;
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return null; // The browser decides what an unparseable URL means.
|
|
510
|
+
}
|
|
511
|
+
if (protocol === "http:" || protocol === "https:") {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
if (isUrlAllowed(url, list)) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
this.log(`blocked: profile=${profile} url=${loggableUrl(url)}`);
|
|
518
|
+
return blockedMessage(profile, list, url);
|
|
519
|
+
}
|
|
520
|
+
/** Context-level events: they see popups and service-worker requests that no page event does. */
|
|
521
|
+
watchBlocks(profile, context) {
|
|
522
|
+
const ledger = this.ledgerFor(profile);
|
|
523
|
+
const attribute = (request) => (refusedAt) => {
|
|
524
|
+
let frame = null;
|
|
525
|
+
let page = null;
|
|
526
|
+
let navigation = false;
|
|
527
|
+
try {
|
|
528
|
+
frame = request.frame();
|
|
529
|
+
page = frame.page();
|
|
530
|
+
navigation =
|
|
531
|
+
request.isNavigationRequest() && frame.parentFrame() === null;
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
// A request with no frame (e.g. a service worker's).
|
|
535
|
+
}
|
|
536
|
+
if (page === null) {
|
|
537
|
+
this.log(`blocked request not attributed to a page: profile=${profile} url=${loggableUrl(request.url())}`);
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
url: request.url(),
|
|
541
|
+
navigation,
|
|
542
|
+
page,
|
|
543
|
+
frame,
|
|
544
|
+
...(navigation && frame
|
|
545
|
+
? {
|
|
546
|
+
settled: this.whenReplaced(profile, frame, refusedAt, request.url()),
|
|
547
|
+
}
|
|
548
|
+
: {}),
|
|
549
|
+
};
|
|
550
|
+
};
|
|
551
|
+
context.on("requestfailed", (request) => {
|
|
552
|
+
ledger.claimConnect(request.url(), attribute(request));
|
|
553
|
+
});
|
|
554
|
+
context.on("response", (response) => {
|
|
555
|
+
if (response.headers()["x-anybrowser-blocked"] === "1") {
|
|
556
|
+
ledger.claimHttp(response.url(), attribute(response.request()));
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
const track = (page) => {
|
|
560
|
+
page.on("framenavigated", (frame) => {
|
|
561
|
+
this.commitTimes.set(frame, Date.now());
|
|
562
|
+
});
|
|
563
|
+
};
|
|
564
|
+
context.pages().forEach(track);
|
|
565
|
+
context.on("page", track);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* After a top-level navigation is refused, the tab commits something in
|
|
569
|
+
* its place (Chromium's own error page, or our block page) a moment later,
|
|
570
|
+
* and a navigation issued before that lands fails with "interrupted by
|
|
571
|
+
* another navigation". Resolves when THIS frame has committed after the
|
|
572
|
+
* refusal (so one already showing from an earlier block cannot satisfy it),
|
|
573
|
+
* when the page goes away, or after a deadline, which it logs.
|
|
574
|
+
*/
|
|
575
|
+
whenReplaced(profile, frame, refusedAt, url) {
|
|
576
|
+
if ((this.commitTimes.get(frame) ?? 0) >= refusedAt) {
|
|
577
|
+
return Promise.resolve();
|
|
578
|
+
}
|
|
579
|
+
return new Promise((resolve) => {
|
|
580
|
+
const page = frame.page();
|
|
581
|
+
const done = () => {
|
|
582
|
+
clearTimeout(timer);
|
|
583
|
+
page.off("framenavigated", onNavigated);
|
|
584
|
+
page.off("close", done);
|
|
585
|
+
resolve();
|
|
586
|
+
};
|
|
587
|
+
const onNavigated = (navigated) => {
|
|
588
|
+
if (navigated === frame) {
|
|
589
|
+
done();
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
const timer = setTimeout(() => {
|
|
593
|
+
this.log(`blocked navigation did not settle: profile=${profile} url=${loggableUrl(url)} waited=${BLOCKED_NAVIGATION_SETTLE_MS}ms`);
|
|
594
|
+
done();
|
|
595
|
+
}, BLOCKED_NAVIGATION_SETTLE_MS);
|
|
596
|
+
page.on("framenavigated", onNavigated);
|
|
597
|
+
page.on("close", done);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Moves a tab that a refused navigation left on an error page to
|
|
602
|
+
* about:blank (which cannot retry), and writes `message` into it so the
|
|
603
|
+
* person watching the window can see what was refused.
|
|
604
|
+
*/
|
|
605
|
+
async parkRefusedTab(profile, page, message) {
|
|
606
|
+
if (page === null || page.isClosed()) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
await page.goto("about:blank");
|
|
611
|
+
await page.setContent(`<!doctype html><meta charset="utf-8"><title>Blocked</title><p>${escapeHtml(message)}</p>`);
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
this.log(`could not park refused tab: profile=${profile} ${error instanceof Error ? error.message : String(error)}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/** Ids of profiles with a currently running (successfully launched) browser, sorted. */
|
|
618
|
+
runningProfileIds() {
|
|
619
|
+
return [...this.runningContexts.keys()].sort();
|
|
620
|
+
}
|
|
621
|
+
/** The running context for `profile`, or undefined if it isn't currently running. */
|
|
622
|
+
runningContext(profile) {
|
|
623
|
+
return this.runningContexts.get(profile);
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Closes every open browser context so each one flushes its cookie store
|
|
627
|
+
* (and the rest of its profile state) to disk before the daemon exits: the
|
|
628
|
+
* whole point of anybrowser is that a profile stays logged in
|
|
629
|
+
* (docs/DESIGN.md decision 2), and an abrupt `process.exit()` without this
|
|
630
|
+
* kills Chromium before it ever writes the cookie it just set.
|
|
631
|
+
*
|
|
632
|
+
* Awaits every close, bounded by a few seconds per context and an overall
|
|
633
|
+
* cap of about 10 seconds, so a single hung browser can never stop the
|
|
634
|
+
* daemon from exiting: a timed-out or errored close is logged and skipped
|
|
635
|
+
* rather than retried or rethrown.
|
|
636
|
+
*/
|
|
637
|
+
async closeAll() {
|
|
638
|
+
const entries = [...this.runningContexts.entries()];
|
|
639
|
+
// Evicted before closing, so their close events are not taken for a
|
|
640
|
+
// browser that went away on its own.
|
|
641
|
+
this.runningContexts.clear();
|
|
642
|
+
const OVERALL_CAP_MS = 10000;
|
|
643
|
+
const overallDeadline = Date.now() + OVERALL_CAP_MS;
|
|
644
|
+
for (const profile of this.idleTimers.keys()) {
|
|
645
|
+
this.clearIdleTimer(profile);
|
|
646
|
+
}
|
|
647
|
+
await Promise.all(entries.map(async ([profile, context]) => {
|
|
648
|
+
const timeoutMs = Math.max(0, Math.min(PER_CONTEXT_CLOSE_TIMEOUT_MS, overallDeadline - Date.now()));
|
|
649
|
+
this.bumpGeneration(profile);
|
|
650
|
+
const proxy = this.proxies.get(profile);
|
|
651
|
+
this.proxies.delete(profile);
|
|
652
|
+
const closed = await this.closeOneContext(profile, context, timeoutMs);
|
|
653
|
+
await this.releaseProxy(profile, proxy, closed);
|
|
654
|
+
}));
|
|
655
|
+
this.contexts.clear();
|
|
656
|
+
this.runningHeadless.clear();
|
|
657
|
+
this.lastActivity.clear();
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Launches `profile` HEADED (unless the global ANYBROWSER_HEADLESS
|
|
661
|
+
* override is set) if it isn't already running, opens `url` in a new tab
|
|
662
|
+
* and brings it to front (decision 12). If the profile is already running
|
|
663
|
+
* headless without the global override, throws
|
|
664
|
+
* `ProfileHeadlessRunningError` rather than silently relaunching under a
|
|
665
|
+
* live session (out of scope for this slice).
|
|
666
|
+
*/
|
|
667
|
+
async loginLaunch(profile, url) {
|
|
668
|
+
const alreadyRunning = this.runningContexts.get(profile);
|
|
669
|
+
let context;
|
|
670
|
+
if (alreadyRunning) {
|
|
671
|
+
const headless = this.runningHeadless.get(profile) ?? false;
|
|
672
|
+
if (headless && !isHeadless()) {
|
|
673
|
+
throw new ProfileHeadlessRunningError(`profile '${profile}' is already running headless; restart it headed first (e.g. 'anyb stop') before logging in`);
|
|
674
|
+
}
|
|
675
|
+
context = alreadyRunning;
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
context = await this.getContext(profile, isHeadless());
|
|
679
|
+
}
|
|
680
|
+
const allowedOrigins = this.currentAllowedOrigins(profile);
|
|
681
|
+
if (allowedOrigins?.length && !isUrlAllowed(url, allowedOrigins)) {
|
|
682
|
+
this.log(`blocked: profile=${profile} url=${loggableUrl(url)}`);
|
|
683
|
+
throw new ProfileUrlBlockedError(blockedMessage(profile, allowedOrigins, url));
|
|
684
|
+
}
|
|
685
|
+
this.beginActivity(profile);
|
|
686
|
+
try {
|
|
687
|
+
const mark = this.blockMark(profile);
|
|
688
|
+
const page = await context.newPage();
|
|
689
|
+
let failure;
|
|
690
|
+
try {
|
|
691
|
+
await page.goto(url);
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
failure = error;
|
|
695
|
+
}
|
|
696
|
+
// The proxy's refusal decides whether this was a block; what the page
|
|
697
|
+
// went through (a redirect off the list, a refused tunnel) decides nothing.
|
|
698
|
+
const blocked = (await this.blocksSince(profile, mark)).find((b) => b.navigation && b.page === page);
|
|
699
|
+
if (blocked && allowedOrigins?.length) {
|
|
700
|
+
await page.close().catch(() => { });
|
|
701
|
+
throw new ProfileUrlBlockedError(blockedMessage(profile, allowedOrigins, blocked.url));
|
|
702
|
+
}
|
|
703
|
+
if (failure !== undefined) {
|
|
704
|
+
throw failure;
|
|
705
|
+
}
|
|
706
|
+
await page.bringToFront();
|
|
707
|
+
}
|
|
708
|
+
finally {
|
|
709
|
+
this.endActivity(profile);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
async launch(profile, headlessOverride) {
|
|
713
|
+
const stored = readProfile(this.paths, profile);
|
|
714
|
+
if (!stored) {
|
|
715
|
+
const problem = allowedOriginsProblem(this.paths, profile);
|
|
716
|
+
if (problem) {
|
|
717
|
+
throw new Error(`profile '${profile}': ${problem}`);
|
|
718
|
+
}
|
|
719
|
+
// Should not normally happen: BrowserSession checks existence before
|
|
720
|
+
// calling getContext. Guards against a profile removed mid-flight.
|
|
721
|
+
throw new Error(`unknown profile '${profile}'`);
|
|
722
|
+
}
|
|
723
|
+
const profileDir = join(this.paths.home, "profiles", profile);
|
|
724
|
+
const userDataDir = join(profileDir, "user-data");
|
|
725
|
+
const downloadsDir = join(profileDir, "downloads");
|
|
726
|
+
// Headless if the profile says so OR the global override is set; the
|
|
727
|
+
// global override remains available for tests and CI regardless of what
|
|
728
|
+
// any individual profile stores. A caller-supplied override (login)
|
|
729
|
+
// replaces the profile's own flag entirely instead of OR-ing with it.
|
|
730
|
+
const headless = headlessOverride !== undefined
|
|
731
|
+
? headlessOverride
|
|
732
|
+
: stored.headless || isHeadless();
|
|
733
|
+
this.runningHeadless.set(profile, headless);
|
|
734
|
+
const executableLabel = stored.executablePath
|
|
735
|
+
? basename(stored.executablePath)
|
|
736
|
+
: "playwright-chromium";
|
|
737
|
+
// Logged before anything that can fail (directory creation, the launch
|
|
738
|
+
// itself), so this line always marks one launch ATTEMPT, whether or not
|
|
739
|
+
// it succeeds (see getContext's failure handling below).
|
|
740
|
+
this.log(`browser launch: profile=${profile} browser=${stored.browser} headless=${headless} executable=${executableLabel}`);
|
|
741
|
+
mkdirSync(userDataDir, { recursive: true });
|
|
742
|
+
mkdirSync(downloadsDir, { recursive: true });
|
|
743
|
+
// Test-only seam: lets tests observe the launch decision (this log line)
|
|
744
|
+
// without ever opening a real browser, headed or headless. Never set
|
|
745
|
+
// outside tests.
|
|
746
|
+
if (process.env["ANYBROWSER_TEST_NO_LAUNCH"] === "1") {
|
|
747
|
+
throw new Error("ANYBROWSER_TEST_NO_LAUNCH: browser launch skipped for tests");
|
|
748
|
+
}
|
|
749
|
+
// Every supported browser (decision 4) launches through Playwright's
|
|
750
|
+
// `chromium` driver: for `chromium` itself with its bundled build (no
|
|
751
|
+
// `executablePath`), for every other Chromium-family browser by pointing
|
|
752
|
+
// that same driver at the installed executable.
|
|
753
|
+
// The listener exists before the browser, for every profile (a pinned one
|
|
754
|
+
// enforces its list; an unpinned one passes everything through). The port
|
|
755
|
+
// is a launch flag, so a conditional listener would make turning a list on
|
|
756
|
+
// need a restart, and an edit must apply to a running session at once.
|
|
757
|
+
const proxy = await SiteProxy.start({
|
|
758
|
+
profile,
|
|
759
|
+
allowedOrigins: () => this.currentAllowedOrigins(profile),
|
|
760
|
+
isOwnPort: (port) => this.proxyPorts.has(port),
|
|
761
|
+
describeBlock: (url) => blockedMessage(profile, this.currentAllowedOrigins(profile) ?? [], url),
|
|
762
|
+
onRefusal: (refusal) => {
|
|
763
|
+
this.ledgerFor(profile).record(refusal);
|
|
764
|
+
this.log(`blocked: profile=${profile} url=${loggableUrl(refusal.url)}`);
|
|
765
|
+
},
|
|
766
|
+
log: this.log,
|
|
767
|
+
});
|
|
768
|
+
this.proxyPorts.add(proxy.port);
|
|
769
|
+
this.proxies.set(profile, proxy);
|
|
770
|
+
// Test-only: trust exactly the certificate whose public key hashes to
|
|
771
|
+
// this value, so a test can serve HTTPS locally. It pins one key; it is
|
|
772
|
+
// not a switch that turns certificate checking off.
|
|
773
|
+
const trustedSpki = process.env["ANYBROWSER_TEST_TRUSTED_SPKI"];
|
|
774
|
+
let context;
|
|
775
|
+
try {
|
|
776
|
+
context = await chromium.launchPersistentContext(userDataDir, {
|
|
777
|
+
headless,
|
|
778
|
+
// No `bypass`: any loopback host there would switch off Chromium's
|
|
779
|
+
// forced proxying of loopback, and local servers must be checked too.
|
|
780
|
+
proxy: { server: `http://127.0.0.1:${proxy.port}` },
|
|
781
|
+
...(trustedSpki
|
|
782
|
+
? { args: [`--ignore-certificate-errors-spki-list=${trustedSpki}`] }
|
|
783
|
+
: {}),
|
|
784
|
+
// The daemon is the sole owner of browser lifetime (decision 5) and
|
|
785
|
+
// already installs its own SIGINT/SIGTERM handlers (daemon.ts) that
|
|
786
|
+
// close every context gracefully before exiting. Without these three
|
|
787
|
+
// flags, Playwright installs ITS OWN process-wide signal handlers that
|
|
788
|
+
// independently race to close the same browser process the instant a
|
|
789
|
+
// signal arrives - confirmed by a spike: on SIGTERM, Playwright's own
|
|
790
|
+
// handler and our explicit `context.close()` call both initiate a
|
|
791
|
+
// close of the same browser concurrently, and whichever tears down
|
|
792
|
+
// the browser process first can do so before the other's cookie
|
|
793
|
+
// flush lands, losing a cookie set moments earlier nondeterministically.
|
|
794
|
+
handleSIGINT: false,
|
|
795
|
+
handleSIGTERM: false,
|
|
796
|
+
handleSIGHUP: false,
|
|
797
|
+
...(stored.executablePath
|
|
798
|
+
? { executablePath: stored.executablePath }
|
|
799
|
+
: {}),
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
// The browser never came up, so nothing can be using the port.
|
|
804
|
+
this.proxies.delete(profile);
|
|
805
|
+
await proxy.close();
|
|
806
|
+
throw error;
|
|
807
|
+
}
|
|
808
|
+
this.watchBlocks(profile, context);
|
|
809
|
+
const first = context.pages()[0];
|
|
810
|
+
if (first) {
|
|
811
|
+
this.firstPages.set(profile, first);
|
|
812
|
+
}
|
|
813
|
+
return context;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Closes the tabs a disconnected session opened: `owned` plus any popup
|
|
817
|
+
* those pages opened. The profile's first page, and every page that is
|
|
818
|
+
* neither, are left alone, and so is the browser. A browser that is gone or
|
|
819
|
+
* a page that will not close is logged and skipped, never thrown.
|
|
820
|
+
*/
|
|
821
|
+
async closeSessionPages(profile, owned) {
|
|
822
|
+
const context = this.runningContexts.get(profile);
|
|
823
|
+
if (!context || owned.length === 0) {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
const first = this.firstPages.get(profile);
|
|
828
|
+
const doomed = new Set();
|
|
829
|
+
for (const page of context.pages()) {
|
|
830
|
+
if (page === first || page.isClosed()) {
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
if (owned.includes(page)) {
|
|
834
|
+
doomed.add(page);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
for (const ancestor of owned) {
|
|
838
|
+
if (await openedBy(page, ancestor)) {
|
|
839
|
+
doomed.add(page);
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
for (const page of doomed) {
|
|
845
|
+
await page.close().catch((error) => {
|
|
846
|
+
this.log(`could not close session tab: profile=${profile} ${error instanceof Error ? error.message : String(error)}`);
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
catch (error) {
|
|
851
|
+
this.log(`could not close session tabs: profile=${profile} ${error instanceof Error ? error.message : String(error)}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Per MCP session (one per `anyb mcp` proxy connection): lists the browser
|
|
857
|
+
* tool surface (profile added, `browser_close`/`browser_install` removed)
|
|
858
|
+
* without touching the browser, and routes `browser_*` tool calls to a
|
|
859
|
+
* lazily created embedded `@playwright/mcp` connection for the named
|
|
860
|
+
* profile, over the daemon-wide shared persistent context.
|
|
861
|
+
*/
|
|
862
|
+
export class BrowserSession {
|
|
863
|
+
router;
|
|
864
|
+
toolsPromise;
|
|
865
|
+
connections = new Map();
|
|
866
|
+
/** The router generation each cached connection was opened at (see `BrowserContextRouter.generationOf`). */
|
|
867
|
+
connectionGenerations = new Map();
|
|
868
|
+
/** The tabs this session opened, per profile; closed when the session ends. */
|
|
869
|
+
ownedPages = new Map();
|
|
870
|
+
constructor(router) {
|
|
871
|
+
this.router = router;
|
|
872
|
+
}
|
|
873
|
+
/** Browser tools with `profile` added, minus the removed tools. Never launches a browser. */
|
|
874
|
+
async listTools() {
|
|
875
|
+
if (!this.toolsPromise) {
|
|
876
|
+
this.toolsPromise = this.fetchUpstreamTools();
|
|
877
|
+
}
|
|
878
|
+
const tools = await this.toolsPromise;
|
|
879
|
+
return tools.map(addProfileProperty);
|
|
880
|
+
}
|
|
881
|
+
/** Calls a browser tool by its (profile-added) name; `profile` is required in `args`. */
|
|
882
|
+
async callTool(name, args) {
|
|
883
|
+
if (!name.startsWith("browser_") || REMOVED_TOOLS.has(name)) {
|
|
884
|
+
throw new McpError(ErrorCode.MethodNotFound, `unknown tool '${name}'`);
|
|
885
|
+
}
|
|
886
|
+
const { profile, ...upstreamArgs } = args;
|
|
887
|
+
if (typeof profile !== "string" || profile.length === 0) {
|
|
888
|
+
throw new McpError(ErrorCode.InvalidParams, `tool '${name}' requires a string 'profile' argument`);
|
|
889
|
+
}
|
|
890
|
+
if (!profileExists(this.router.paths, profile)) {
|
|
891
|
+
throw new McpError(ErrorCode.InvalidParams, `unknown profile '${profile}'`);
|
|
892
|
+
}
|
|
893
|
+
if (name === "browser_navigate" &&
|
|
894
|
+
typeof upstreamArgs["url"] === "string") {
|
|
895
|
+
// `file:` and its kin never cross the proxy, so the wrapper is the only
|
|
896
|
+
// place a pinned profile can refuse them.
|
|
897
|
+
const refusal = this.router.refuseNonNetworkUrl(profile, upstreamArgs["url"]);
|
|
898
|
+
if (refusal !== null) {
|
|
899
|
+
return {
|
|
900
|
+
isError: true,
|
|
901
|
+
content: [{ type: "text", text: `### Error\n${refusal}` }],
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
this.router.beginActivity(profile);
|
|
906
|
+
try {
|
|
907
|
+
const connection = await this.getConnection(profile);
|
|
908
|
+
if (connection.current === undefined || connection.current?.isClosed()) {
|
|
909
|
+
await this.takeOwnTab(profile, connection);
|
|
910
|
+
}
|
|
911
|
+
const mark = this.router.blockMark(profile);
|
|
912
|
+
const result = await connection.client.callTool({
|
|
913
|
+
name,
|
|
914
|
+
arguments: upstreamArgs,
|
|
915
|
+
});
|
|
916
|
+
if (name === "browser_tabs" && upstreamArgs["action"] === "new") {
|
|
917
|
+
await this.adoptCurrentPage(profile, connection);
|
|
918
|
+
}
|
|
919
|
+
else if (name === "browser_tabs" &&
|
|
920
|
+
upstreamArgs["action"] === "select") {
|
|
921
|
+
connection.current = await this.currentPage(profile, connection);
|
|
922
|
+
}
|
|
923
|
+
const typed = result;
|
|
924
|
+
const content = absolutizeSnapshotLinks(typed.content);
|
|
925
|
+
const allowedOrigins = this.router.currentAllowedOrigins(profile);
|
|
926
|
+
const blocks = await this.router.blocksSince(profile, connection.noteMark ?? mark);
|
|
927
|
+
connection.noteMark = this.router.blockMark(profile);
|
|
928
|
+
if (blocks.length === 0 || !allowedOrigins?.length) {
|
|
929
|
+
return { ...typed, content };
|
|
930
|
+
}
|
|
931
|
+
// Only a block on THIS session's own current page is this call's
|
|
932
|
+
// error. A block on another session's tab is not, and says nothing here.
|
|
933
|
+
const ownPage = await this.currentPage(profile, connection);
|
|
934
|
+
const own = [];
|
|
935
|
+
const notes = [];
|
|
936
|
+
for (const block of blocks) {
|
|
937
|
+
if (block.navigation && block.seq <= mark) {
|
|
938
|
+
// Refused between calls: no call's navigation, so not an error.
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
if (block.page !== null && block.page === ownPage) {
|
|
942
|
+
(block.navigation ? own : notes).push(block);
|
|
943
|
+
}
|
|
944
|
+
else if (block.page === null ||
|
|
945
|
+
(ownPage !== null && (await openedBy(block.page, ownPage)))) {
|
|
946
|
+
notes.push(block);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (own.length === 0 && notes.length === 0) {
|
|
950
|
+
return { ...typed, content };
|
|
951
|
+
}
|
|
952
|
+
// The tab is mid-way to what replaces it; the caller's next navigation
|
|
953
|
+
// would race it (see BrowserContextRouter.whenReplaced).
|
|
954
|
+
await Promise.all(own.map((b) => b.settled));
|
|
955
|
+
if (own.length > 0) {
|
|
956
|
+
// Chromium's error page retries the refused URL on its own, forever;
|
|
957
|
+
// leave the tab somewhere that cannot.
|
|
958
|
+
const shown = describeUrls([...own, ...notes]);
|
|
959
|
+
await this.router.parkRefusedTab(profile, ownPage, blockedMessage(profile, allowedOrigins, shown));
|
|
960
|
+
return {
|
|
961
|
+
...typed,
|
|
962
|
+
isError: true,
|
|
963
|
+
content: [
|
|
964
|
+
{
|
|
965
|
+
type: "text",
|
|
966
|
+
text: `### Error\n${blockedMessage(profile, allowedOrigins, shown)}`,
|
|
967
|
+
},
|
|
968
|
+
],
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
const note = incompletePageMessage(profile, allowedOrigins, notes.map((b) => b.url));
|
|
972
|
+
return {
|
|
973
|
+
...typed,
|
|
974
|
+
content: [...content, { type: "text", text: `### Blocked\n${note}` }],
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
finally {
|
|
978
|
+
this.router.endActivity(profile);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Puts this connection on a tab the session opened (decision 10): the most
|
|
983
|
+
* recent one still open, else a fresh one. Upstream starts every connection
|
|
984
|
+
* on the shared context's FIRST page (verified in the design spike), and
|
|
985
|
+
* when a connection's current tab closes it moves that connection to a
|
|
986
|
+
* neighbouring tab; either may be another session's. Both moves go through
|
|
987
|
+
* the upstream tool surface (rather than touching Playwright directly) so
|
|
988
|
+
* upstream's own "current tab" bookkeeping stays correct.
|
|
989
|
+
*/
|
|
990
|
+
async takeOwnTab(profile, connection) {
|
|
991
|
+
const pages = this.router.runningContext(profile)?.pages() ?? [];
|
|
992
|
+
const owned = (this.ownedPages.get(profile) ?? [])
|
|
993
|
+
.filter((page) => pages.includes(page))
|
|
994
|
+
.at(-1);
|
|
995
|
+
if (owned !== undefined) {
|
|
996
|
+
await connection.client.callTool({
|
|
997
|
+
name: "browser_tabs",
|
|
998
|
+
arguments: { action: "select", index: pages.indexOf(owned) },
|
|
999
|
+
});
|
|
1000
|
+
connection.current = owned;
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
await connection.client.callTool({
|
|
1004
|
+
name: "browser_tabs",
|
|
1005
|
+
arguments: { action: "new" },
|
|
1006
|
+
});
|
|
1007
|
+
await this.adoptCurrentPage(profile, connection);
|
|
1008
|
+
}
|
|
1009
|
+
/** Records this connection's current tab, just opened by it, as one the session owns. */
|
|
1010
|
+
async adoptCurrentPage(profile, connection) {
|
|
1011
|
+
const page = await this.currentPage(profile, connection);
|
|
1012
|
+
connection.current = page;
|
|
1013
|
+
if (page) {
|
|
1014
|
+
this.ownedPages.set(profile, [
|
|
1015
|
+
...(this.ownedPages.get(profile) ?? []),
|
|
1016
|
+
page,
|
|
1017
|
+
]);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/** Closes the tabs this session opened (and their popups); called when its socket closes. */
|
|
1021
|
+
async dispose() {
|
|
1022
|
+
for (const [profile, pages] of this.ownedPages) {
|
|
1023
|
+
await this.router.closeSessionPages(profile, pages);
|
|
1024
|
+
}
|
|
1025
|
+
this.ownedPages.clear();
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* The page this connection's current tab is. Upstream keeps that per
|
|
1029
|
+
* connection and does not expose it, so it is read from the tab list
|
|
1030
|
+
* (`- 1: (current) ...`), whose order is the context's own page order.
|
|
1031
|
+
* Null when it cannot be told, which treats nothing as this call's own.
|
|
1032
|
+
*/
|
|
1033
|
+
async currentPage(profile, connection) {
|
|
1034
|
+
const context = this.router.runningContext(profile);
|
|
1035
|
+
if (!context) {
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
const tabs = (await connection.client.callTool({
|
|
1039
|
+
name: "browser_tabs",
|
|
1040
|
+
arguments: { action: "list" },
|
|
1041
|
+
}));
|
|
1042
|
+
const text = (tabs.content ?? []).map((item) => item.text ?? "").join("\n");
|
|
1043
|
+
const index = /^- (\d+): \(current\)/m.exec(text)?.[1];
|
|
1044
|
+
return index === undefined
|
|
1045
|
+
? null
|
|
1046
|
+
: (context.pages()[Number(index)] ?? null);
|
|
1047
|
+
}
|
|
1048
|
+
async fetchUpstreamTools() {
|
|
1049
|
+
// Any profile's upstream connection reports the same tool set (the set
|
|
1050
|
+
// is fixed by our config, not by which profile is running), and asking
|
|
1051
|
+
// for it never touches `contextGetter`, so this never launches a
|
|
1052
|
+
// browser and never requires any real profile to exist.
|
|
1053
|
+
const { client } = await this.getConnection(TOOLS_SCHEMA_KEY);
|
|
1054
|
+
const upstream = await client.listTools();
|
|
1055
|
+
return upstream.tools.filter((tool) => !REMOVED_TOOLS.has(tool.name));
|
|
1056
|
+
}
|
|
1057
|
+
getConnection(profile) {
|
|
1058
|
+
// A profile idle-closed (or closed by daemon shutdown, or whose browser
|
|
1059
|
+
// went away on its own) since this connection was opened bumps its
|
|
1060
|
+
// generation at the router: drop the stale connection (bound to a
|
|
1061
|
+
// now-closed context) so the call below opens a fresh one over the
|
|
1062
|
+
// profile's freshly relaunched context.
|
|
1063
|
+
const currentGeneration = this.router.generationOf(profile);
|
|
1064
|
+
if (this.connectionGenerations.get(profile) !== currentGeneration) {
|
|
1065
|
+
this.connections.delete(profile);
|
|
1066
|
+
}
|
|
1067
|
+
let connection = this.connections.get(profile);
|
|
1068
|
+
if (!connection) {
|
|
1069
|
+
this.connectionGenerations.set(profile, currentGeneration);
|
|
1070
|
+
// A failed open (e.g. the underlying browser launch failed) must not
|
|
1071
|
+
// be cached forever: evict it so the next call retries (same reasoning
|
|
1072
|
+
// as BrowserContextRouter.getContext; the launch failure itself is
|
|
1073
|
+
// already logged there).
|
|
1074
|
+
connection = this.openConnection(profile).catch((error) => {
|
|
1075
|
+
this.connections.delete(profile);
|
|
1076
|
+
throw error;
|
|
1077
|
+
});
|
|
1078
|
+
this.connections.set(profile, connection);
|
|
1079
|
+
}
|
|
1080
|
+
return connection;
|
|
1081
|
+
}
|
|
1082
|
+
async openConnection(profile) {
|
|
1083
|
+
const config = {
|
|
1084
|
+
browser: { isolated: false },
|
|
1085
|
+
sharedBrowserContext: true,
|
|
1086
|
+
outputDir: join(this.router.paths.home, "profiles", profile, "downloads"),
|
|
1087
|
+
};
|
|
1088
|
+
const server = await createConnection(config, () => this.router.getContext(profile));
|
|
1089
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
1090
|
+
await server.connect(serverTransport);
|
|
1091
|
+
const client = new Client({ name: "anybrowser-daemon", version: "0.0.0" });
|
|
1092
|
+
await client.connect(clientTransport);
|
|
1093
|
+
return { client };
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
//# sourceMappingURL=browser.js.map
|