Skip to content

Commit bb46e45

Browse files
Hein van Vuurenclaude
andcommitted
feat: Phase 3 — profile-aware browser, Browser Use provider, profile tools
Wire profile system into browser.ts with ensureBrowserForProfile() supporting multi-browser instances (managed launch + existing-session attach). Add Browser Use cloud provider. Add list_profiles MCP tool showing profiles, sessions, and provider status. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9fbf8a1 commit bb46e45

5 files changed

Lines changed: 267 additions & 0 deletions

File tree

src/browser.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
} from './third_party/index.js';
1818
import {puppeteer} from './third_party/index.js';
1919
import {applyGhostMode, type GhostModeConfig} from './ghost-mode.js';
20+
import type {ResolvedProfile} from './config/profiles.js';
2021

2122
let browser: Browser | undefined;
2223

@@ -251,3 +252,113 @@ export async function ensureBrowserLaunched(
251252
}
252253

253254
export type Channel = 'stable' | 'canary' | 'beta' | 'dev';
255+
256+
// --- Profile-based browser management ---
257+
258+
const profileBrowsers = new Map<string, Browser>();
259+
260+
/**
261+
* Launch or connect a browser for a ResolvedProfile.
262+
*
263+
* - 'managed' profiles launch a new Chrome via `launch()`.
264+
* - 'existing-session' profiles connect to a running Chrome via `ensureBrowserConnected()`.
265+
*
266+
* Returns the Browser instance and caches it by profile name.
267+
* If a connected browser already exists for the profile, returns it immediately.
268+
*/
269+
export async function ensureBrowserForProfile(
270+
profile: ResolvedProfile,
271+
ghostMode?: Partial<GhostModeConfig>,
272+
): Promise<Browser> {
273+
const existing = profileBrowsers.get(profile.name);
274+
if (existing?.connected) {
275+
return existing;
276+
}
277+
278+
let instance: Browser;
279+
280+
if (profile.driver === 'managed') {
281+
const userDataDir =
282+
profile.userDataDir ??
283+
path.join(
284+
os.homedir(),
285+
'.boss-ghost',
286+
'profiles',
287+
profile.name,
288+
'chrome-data',
289+
);
290+
291+
const args: string[] = [...profile.extraArgs];
292+
if (profile.cdpPort) {
293+
args.push(`--remote-debugging-port=${profile.cdpPort}`);
294+
}
295+
296+
instance = await launch({
297+
headless: profile.headless,
298+
channel: profile.channel,
299+
executablePath: profile.executablePath,
300+
userDataDir,
301+
args,
302+
isolated: false,
303+
devtools: false,
304+
ghostMode,
305+
});
306+
} else {
307+
// existing-session: connect to a running browser
308+
if (profile.cdpUrl) {
309+
instance = await ensureBrowserConnected({
310+
wsEndpoint: profile.cdpUrl,
311+
devtools: false,
312+
});
313+
} else {
314+
instance = await ensureBrowserConnected({
315+
browserURL: `http://127.0.0.1:${profile.cdpPort}`,
316+
devtools: false,
317+
});
318+
}
319+
320+
// Apply ghost mode to connected browsers manually since launch() won't do it
321+
if (ghostMode) {
322+
await applyGhostMode(instance, ghostMode);
323+
logger('Ghost Mode applied to connected profile "%s"', profile.name);
324+
}
325+
}
326+
327+
profileBrowsers.set(profile.name, instance);
328+
logger('Browser ready for profile "%s" (driver=%s)', profile.name, profile.driver);
329+
return instance;
330+
}
331+
332+
/**
333+
* Retrieve a cached browser instance by profile name.
334+
* Returns undefined if no browser exists or it has disconnected.
335+
*/
336+
export function getBrowserForProfile(name: string): Browser | undefined {
337+
const instance = profileBrowsers.get(name);
338+
if (instance && !instance.connected) {
339+
profileBrowsers.delete(name);
340+
return undefined;
341+
}
342+
return instance;
343+
}
344+
345+
/**
346+
* Close/disconnect a profile's browser and remove it from the cache.
347+
*/
348+
export async function closeBrowserForProfile(name: string): Promise<void> {
349+
const instance = profileBrowsers.get(name);
350+
if (!instance) {
351+
return;
352+
}
353+
354+
profileBrowsers.delete(name);
355+
356+
if (instance.connected) {
357+
try {
358+
await instance.close();
359+
logger('Closed browser for profile "%s"', name);
360+
} catch (err) {
361+
logger('Error closing browser for profile "%s": %s', name, err);
362+
}
363+
}
364+
}

src/providers/browser-use.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Boss Ghost MCP — Browser Use cloud browser provider.
3+
*
4+
* Creates and manages browser sessions via the Browser Use API.
5+
* Ported from Hermes's browser_providers/browser_use.py.
6+
*/
7+
8+
import type {CloudBrowserProvider, ProviderSession} from './base.js';
9+
import {logger} from '../logger.js';
10+
11+
const BROWSER_USE_API = 'https://api.browser-use.com/api/v2';
12+
13+
interface BrowserUseConfig {
14+
apiKey?: string;
15+
}
16+
17+
export class BrowserUseProvider implements CloudBrowserProvider {
18+
readonly providerName = 'browser-use';
19+
private apiKey: string;
20+
21+
constructor(config?: BrowserUseConfig) {
22+
this.apiKey = config?.apiKey ?? process.env['BROWSER_USE_API_KEY'] ?? '';
23+
}
24+
25+
isConfigured(): boolean {
26+
return this.apiKey.length > 0;
27+
}
28+
29+
async createSession(taskId?: string): Promise<ProviderSession> {
30+
if (!this.isConfigured()) {
31+
throw new Error(
32+
'Browser Use not configured — set BROWSER_USE_API_KEY, ' +
33+
'or configure it in ~/.boss-ghost/config.json under providers.browser-use',
34+
);
35+
}
36+
37+
const response = await fetch(`${BROWSER_USE_API}/browsers`, {
38+
method: 'POST',
39+
headers: this.headers(),
40+
body: JSON.stringify({}),
41+
});
42+
43+
if (!response.ok) {
44+
const text = await response.text().catch(() => '');
45+
throw new Error(`Browser Use API error ${response.status}: ${text}`);
46+
}
47+
48+
const data = (await response.json()) as {id: string; cdpUrl: string};
49+
const sessionName = taskId ? `boss-ghost-${taskId}` : `boss-ghost-${Date.now()}`;
50+
51+
logger('Browser Use session created: %s (id=%s)', sessionName, data.id);
52+
53+
return {
54+
sessionId: data.id,
55+
sessionName,
56+
cdpUrl: data.cdpUrl,
57+
features: {browserUse: true},
58+
};
59+
}
60+
61+
async closeSession(sessionId: string): Promise<boolean> {
62+
const response = await fetch(`${BROWSER_USE_API}/browsers/${sessionId}`, {
63+
method: 'PATCH',
64+
headers: this.headers(),
65+
body: JSON.stringify({action: 'stop'}),
66+
});
67+
68+
if (!response.ok) {
69+
logger('Browser Use closeSession failed: %s %s', response.status, response.statusText);
70+
return false;
71+
}
72+
73+
logger('Browser Use session %s stopped', sessionId);
74+
return true;
75+
}
76+
77+
async emergencyCleanup(sessionId: string): Promise<void> {
78+
try {
79+
await this.closeSession(sessionId);
80+
} catch {
81+
// Best-effort — swallow all errors in exit handlers
82+
}
83+
}
84+
85+
private headers(): Record<string, string> {
86+
return {
87+
'Content-Type': 'application/json',
88+
'X-Browser-Use-API-Key': this.apiKey,
89+
};
90+
}
91+
}

src/providers/registry.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import type {CloudBrowserProvider} from './base.js';
1010
import {BrowserbaseProvider} from './browserbase.js';
11+
import {BrowserUseProvider} from './browser-use.js';
1112
import {logger} from '../logger.js';
1213
import {getConfig} from '../config/config.js';
1314

@@ -16,6 +17,7 @@ type ProviderConstructor = new (config?: Record<string, unknown>) => CloudBrowse
1617
/** Registry of known provider constructors, keyed by name. */
1718
const PROVIDER_REGISTRY = new Map<string, ProviderConstructor>([
1819
['browserbase', BrowserbaseProvider as unknown as ProviderConstructor],
20+
['browser-use', BrowserUseProvider as unknown as ProviderConstructor],
1921
]);
2022

2123
/** Cached provider instance (resolved once per process). */
@@ -81,6 +83,14 @@ export function getCloudProvider(): CloudBrowserProvider | null {
8183
return browserbase;
8284
}
8385

86+
// Auto-detect: try Browser Use env vars
87+
const browserUse = new BrowserUseProvider();
88+
if (browserUse.isConfigured()) {
89+
logger('Cloud provider: browser-use (auto-detected from env vars)');
90+
cachedProvider = browserUse;
91+
return browserUse;
92+
}
93+
8494
logger('Cloud provider: local mode (no provider configured)');
8595
cachedProvider = null;
8696
return null;

src/tools/profiles.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Profile management MCP tools.
3+
*/
4+
5+
import {defineTool} from './ToolDefinition.js';
6+
import {ToolCategory} from './categories.js';
7+
import {listProfiles} from '../config/profiles.js';
8+
import {listSessions} from '../config/session-registry.js';
9+
import {listSessionActivity} from '../utils/inactivity.js';
10+
import {listProviders} from '../providers/registry.js';
11+
12+
export const listProfilesTool = defineTool({
13+
name: 'list_profiles',
14+
description:
15+
'List all configured browser profiles with their settings, active sessions, and cloud providers.',
16+
annotations: {
17+
category: ToolCategory.NAVIGATION,
18+
readOnlyHint: true,
19+
},
20+
schema: {},
21+
handler: async (_request, response) => {
22+
const profiles = listProfiles();
23+
const sessions = listSessions();
24+
const activity = listSessionActivity();
25+
const providers = listProviders();
26+
27+
response.appendResponseLine('## Browser Profiles');
28+
for (const p of profiles) {
29+
response.appendResponseLine(
30+
`- **${p.name}** [${p.driver}] port:${p.cdpPort} headless:${p.headless} channel:${p.channel}${p.cdpUrl ? ` cdp:${p.cdpUrl}` : ''}${p.attachOnly ? ' (attach-only)' : ''}`,
31+
);
32+
}
33+
34+
if (sessions.length > 0) {
35+
response.appendResponseLine('\n## Active Sessions');
36+
for (const s of sessions) {
37+
const idle = activity.find(a => a.sessionKey === s.sessionKey);
38+
response.appendResponseLine(
39+
`- ${s.sessionKey}: ${s.tabCount} tab(s)${idle ? ` (idle ${Math.round(idle.idleMs / 1000)}s)` : ''}`,
40+
);
41+
}
42+
}
43+
44+
if (providers.length > 0) {
45+
response.appendResponseLine('\n## Cloud Providers');
46+
for (const p of providers) {
47+
response.appendResponseLine(
48+
`- ${p.name}: ${p.configured ? '✓ configured' : '✗ not configured'}`,
49+
);
50+
}
51+
}
52+
},
53+
});

src/tools/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as inputTools from './input.js';
1010
import * as networkTools from './network.js';
1111
import * as pagesTools from './pages.js';
1212
import * as pdfTools from './pdf.js';
13+
import * as profileTools from './profiles.js';
1314
import * as performanceTools from './performance.js';
1415
import * as screenshotTools from './screenshot.js';
1516
import * as scriptTools from './script.js';
@@ -25,6 +26,7 @@ const tools = [
2526
...Object.values(pagesTools),
2627
...Object.values(pdfTools),
2728
...Object.values(performanceTools),
29+
...Object.values(profileTools),
2830
...Object.values(screenshotTools),
2931
...Object.values(scriptTools),
3032
...Object.values(snapshotTools),

0 commit comments

Comments
 (0)