hyper-agent-browser 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.
@@ -0,0 +1,414 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { chromium } from "patchright";
5
+ import type { Browser, BrowserContext, Page } from "patchright";
6
+ import type { Session } from "../session/store";
7
+ import { syncChromeData } from "./sync-chrome-data";
8
+
9
+ export interface BrowserManagerOptions {
10
+ headed?: boolean;
11
+ timeout?: number;
12
+ channel?: "chrome" | "msedge" | "chromium";
13
+ }
14
+
15
+ export class BrowserManager {
16
+ private browser: Browser | null = null;
17
+ private context: BrowserContext | null = null;
18
+ private page: Page | null = null;
19
+ private session: Session;
20
+ private options: BrowserManagerOptions;
21
+
22
+ constructor(session: Session, options: BrowserManagerOptions = {}) {
23
+ this.session = session;
24
+ this.options = {
25
+ headed: options.headed ?? false,
26
+ timeout: options.timeout ?? 30000,
27
+ channel: options.channel ?? session.channel,
28
+ };
29
+ }
30
+
31
+ async connect(): Promise<void> {
32
+ // Try to reconnect to existing browser via CDP
33
+ if (this.session.wsEndpoint && (await this.isBrowserRunning())) {
34
+ try {
35
+ this.browser = await chromium.connectOverCDP(this.session.wsEndpoint);
36
+ const contexts = this.browser.contexts();
37
+
38
+ if (contexts.length > 0) {
39
+ this.context = contexts[0];
40
+ const pages = this.context.pages();
41
+ this.page = pages.length > 0 ? pages[0] : await this.context.newPage();
42
+ } else {
43
+ // Create new context
44
+ this.context = await this.browser.newContext({
45
+ viewport: { width: 1280, height: 720 },
46
+ });
47
+ this.page = await this.context.newPage();
48
+ }
49
+
50
+ if (this.options.timeout) {
51
+ this.page.setDefaultTimeout(this.options.timeout);
52
+ }
53
+
54
+ console.log(`Reconnected to existing browser (PID: ${this.session.pid})`);
55
+ return;
56
+ } catch (error) {
57
+ console.error("Failed to reconnect, launching new browser:", error);
58
+ }
59
+ }
60
+
61
+ // Launch new browser
62
+ await this.launch();
63
+ }
64
+
65
+ async launch(): Promise<void> {
66
+ // Sync Chrome data from system profile (only if enabled)
67
+ const syncSystemChrome = process.env.HAB_SYNC_CHROME === "true";
68
+ if (syncSystemChrome) {
69
+ console.log("Syncing data from system Chrome profile...");
70
+ syncChromeData(this.session.userDataDir);
71
+ }
72
+
73
+ const launchArgs = [
74
+ "--disable-blink-features=AutomationControlled",
75
+ "--no-first-run",
76
+ "--no-default-browser-check",
77
+ ];
78
+
79
+ // Check if extensions should be loaded (opt-in via environment variable)
80
+ const loadExtensions = process.env.HAB_LOAD_EXTENSIONS === "true";
81
+
82
+ if (loadExtensions) {
83
+ // Load extensions from Chrome profile
84
+ const extensionPaths = this.loadChromeExtensions();
85
+
86
+ // Add extension paths if any found (limit to 10 for better stability)
87
+ if (extensionPaths.length > 0) {
88
+ const limitedExtensions = extensionPaths.slice(0, 10);
89
+ console.log(
90
+ `Loading ${limitedExtensions.length} Chrome extensions (limited from ${extensionPaths.length} total)...`,
91
+ );
92
+ launchArgs.push(`--load-extension=${limitedExtensions.join(",")}`);
93
+
94
+ if (extensionPaths.length > 10) {
95
+ console.log(
96
+ `Note: Limited to 10 extensions to ensure stability. ${extensionPaths.length - 10} extensions skipped.`,
97
+ );
98
+ }
99
+ }
100
+ } else {
101
+ console.log("Extensions disabled by default. Set HAB_LOAD_EXTENSIONS=true to enable.");
102
+ }
103
+
104
+ try {
105
+ // Security: Check if system Keychain should be used (opt-in for security)
106
+ const useSystemKeychain = process.env.HAB_USE_SYSTEM_KEYCHAIN === "true";
107
+
108
+ const ignoreArgs = ["--enable-automation"];
109
+
110
+ if (!useSystemKeychain) {
111
+ // Default: Use isolated password store (more secure)
112
+ launchArgs.push("--password-store=basic");
113
+ console.log(
114
+ "🔒 Using isolated password store (secure mode). Set HAB_USE_SYSTEM_KEYCHAIN=true to use system Keychain.",
115
+ );
116
+ } else {
117
+ // Opt-in: Allow system Keychain access
118
+ ignoreArgs.push("--password-store=basic", "--use-mock-keychain");
119
+ console.log("⚠️ Using system Keychain (HAB_USE_SYSTEM_KEYCHAIN=true)");
120
+ }
121
+
122
+ if (loadExtensions) {
123
+ ignoreArgs.push("--disable-extensions");
124
+ }
125
+
126
+ // Use launchPersistentContext for UserData persistence
127
+ this.context = await chromium.launchPersistentContext(this.session.userDataDir, {
128
+ channel: this.options.channel,
129
+ headless: !this.options.headed,
130
+ args: launchArgs,
131
+ ignoreDefaultArgs: ignoreArgs,
132
+ viewport: { width: 1280, height: 720 },
133
+ });
134
+
135
+ // Extract browser from context
136
+ // @ts-ignore - context has _browser property
137
+ this.browser = this.context._browser;
138
+
139
+ // Get or create page
140
+ const pages = this.context.pages();
141
+ this.page = pages.length > 0 ? pages[0] : await this.context.newPage();
142
+
143
+ // Set default timeout
144
+ if (this.options.timeout) {
145
+ this.page.setDefaultTimeout(this.options.timeout);
146
+ }
147
+
148
+ // @ts-ignore - Browser has process and wsEndpoint methods
149
+ const pid = this.browser?.process?.()?.pid;
150
+ // @ts-ignore
151
+ const wsEndpoint = this.browser?.wsEndpoint?.();
152
+
153
+ console.log(
154
+ `Launched new browser (PID: ${pid}, Extensions: ${loadExtensions ? "enabled" : "disabled"})`,
155
+ );
156
+ } catch (error) {
157
+ console.error("Failed to launch browser:", error);
158
+ throw error;
159
+ }
160
+ }
161
+
162
+ // Security: Whitelist of trusted extension IDs
163
+ // Users can add their trusted extensions here
164
+ private ALLOWED_EXTENSION_IDS = new Set<string>([
165
+ // Example: MetaMask
166
+ // 'nkbihfbeogaeaoehlefnkodbefgpgknn',
167
+ // Add your trusted extensions here
168
+ ]);
169
+
170
+ // Security: Check if extension manifest contains dangerous permissions
171
+ private isExtensionSafe(manifestPath: string): boolean {
172
+ try {
173
+ const { readFileSync } = require("node:fs");
174
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
175
+
176
+ // Check for dangerous permissions
177
+ const dangerousPerms = [
178
+ "debugger",
179
+ "webRequest",
180
+ "proxy",
181
+ "<all_urls>",
182
+ "webRequestBlocking",
183
+ ];
184
+ const permissions = [
185
+ ...(manifest.permissions || []),
186
+ ...(manifest.host_permissions || []),
187
+ ...(manifest.optional_permissions || []),
188
+ ];
189
+
190
+ for (const perm of dangerousPerms) {
191
+ if (permissions.includes(perm)) {
192
+ console.log(`⚠️ Extension blocked: contains dangerous permission '${perm}'`);
193
+ return false;
194
+ }
195
+ }
196
+
197
+ return true;
198
+ } catch (error) {
199
+ console.log(`⚠️ Failed to validate extension manifest: ${error}`);
200
+ return false;
201
+ }
202
+ }
203
+
204
+ private loadChromeExtensions(): string[] {
205
+ const extensionPaths: string[] = [];
206
+
207
+ try {
208
+ // Chrome extensions directory
209
+ const chromeExtensionsDir = join(
210
+ homedir(),
211
+ "Library/Application Support/Google/Chrome/Default/Extensions",
212
+ );
213
+
214
+ if (!existsSync(chromeExtensionsDir)) {
215
+ console.log("Chrome extensions directory not found, skipping extension loading");
216
+ return extensionPaths;
217
+ }
218
+
219
+ // Read all extension directories
220
+ const extensionDirs = readdirSync(chromeExtensionsDir);
221
+
222
+ for (const extensionId of extensionDirs) {
223
+ // Skip hidden files and .DS_Store
224
+ if (extensionId.startsWith(".")) continue;
225
+
226
+ // Security: Whitelist check (if whitelist is not empty, enforce it)
227
+ if (this.ALLOWED_EXTENSION_IDS.size > 0 && !this.ALLOWED_EXTENSION_IDS.has(extensionId)) {
228
+ console.log(`⚠️ Extension ${extensionId} not in whitelist, skipping`);
229
+ continue;
230
+ }
231
+
232
+ const extensionDir = join(chromeExtensionsDir, extensionId);
233
+
234
+ try {
235
+ // Check if it's a directory
236
+ const { statSync } = require("node:fs");
237
+ const stat = statSync(extensionDir);
238
+ if (!stat.isDirectory()) continue;
239
+
240
+ // Read version subdirectories
241
+ const versions = readdirSync(extensionDir);
242
+ const validVersions = versions.filter((v) => !v.startsWith("."));
243
+
244
+ if (validVersions.length === 0) continue;
245
+
246
+ // Sort versions and get the latest (simple lexicographic sort)
247
+ const latestVersion = validVersions.sort().reverse()[0];
248
+ const extensionPath = join(extensionDir, latestVersion);
249
+
250
+ // Verify the extension path exists and has manifest
251
+ const manifestPath = join(extensionPath, "manifest.json");
252
+ if (existsSync(manifestPath)) {
253
+ // Security: Validate extension safety
254
+ if (!this.isExtensionSafe(manifestPath)) {
255
+ console.log(`⚠️ Extension ${extensionId} blocked due to dangerous permissions`);
256
+ continue;
257
+ }
258
+
259
+ extensionPaths.push(extensionPath);
260
+ console.log(`✅ Loaded safe extension: ${extensionId}`);
261
+ }
262
+ } catch (error) {
263
+ // Skip invalid extension directories
264
+ console.log(`Skipping invalid extension: ${extensionId}`);
265
+ }
266
+ }
267
+
268
+ console.log(`Found ${extensionPaths.length} valid and safe Chrome extensions to load`);
269
+ } catch (error) {
270
+ console.error("Error loading Chrome extensions:", error);
271
+ }
272
+
273
+ return extensionPaths;
274
+ }
275
+
276
+ private async isBrowserRunning(): Promise<boolean> {
277
+ if (!this.session.pid) return false;
278
+
279
+ try {
280
+ // Check if process exists
281
+ process.kill(this.session.pid, 0);
282
+ return true;
283
+ } catch {
284
+ return false;
285
+ }
286
+ }
287
+
288
+ async getPage(): Promise<Page> {
289
+ if (!this.page) {
290
+ await this.connect();
291
+ }
292
+ return this.page!;
293
+ }
294
+
295
+ getContext(): BrowserContext | null {
296
+ return this.context;
297
+ }
298
+
299
+ getWsEndpoint(): string | undefined {
300
+ // @ts-ignore - Patchright Browser has wsEndpoint method
301
+ return this.browser?.wsEndpoint?.();
302
+ }
303
+
304
+ getPid(): number | undefined {
305
+ // @ts-ignore - Patchright Browser has process method
306
+ return this.browser?.process?.()?.pid;
307
+ }
308
+
309
+ async close(): Promise<void> {
310
+ if (this.browser) {
311
+ await this.browser.close();
312
+ this.browser = null;
313
+ this.context = null;
314
+ this.page = null;
315
+ }
316
+ }
317
+
318
+ isConnected(): boolean {
319
+ return this.browser !== null && this.page !== null;
320
+ }
321
+
322
+ // Inject visual indicator for agent operations
323
+ async showOperationIndicator(operation: string): Promise<void> {
324
+ if (!this.page || !this.options.headed) return;
325
+
326
+ try {
327
+ await this.page.evaluate((op) => {
328
+ // Remove existing indicator
329
+ const existing = document.getElementById("hab-operation-indicator");
330
+ if (existing) existing.remove();
331
+
332
+ // Create new indicator
333
+ const indicator = document.createElement("div");
334
+ indicator.id = "hab-operation-indicator";
335
+ indicator.innerHTML = `
336
+ <div style="
337
+ position: fixed;
338
+ top: 20px;
339
+ right: 20px;
340
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
341
+ color: white;
342
+ padding: 12px 20px;
343
+ border-radius: 8px;
344
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
345
+ font-size: 14px;
346
+ font-weight: 500;
347
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
348
+ z-index: 2147483647;
349
+ display: flex;
350
+ align-items: center;
351
+ gap: 10px;
352
+ animation: hab-slide-in 0.3s ease-out;
353
+ ">
354
+ <div style="
355
+ width: 8px;
356
+ height: 8px;
357
+ background: #4ade80;
358
+ border-radius: 50%;
359
+ animation: hab-pulse 1.5s ease-in-out infinite;
360
+ "></div>
361
+ <span>🤖 Agent操作中: ${op}</span>
362
+ </div>
363
+ `;
364
+
365
+ // Add animations
366
+ if (!document.getElementById("hab-styles")) {
367
+ const style = document.createElement("style");
368
+ style.id = "hab-styles";
369
+ style.textContent = `
370
+ @keyframes hab-slide-in {
371
+ from { transform: translateX(400px); opacity: 0; }
372
+ to { transform: translateX(0); opacity: 1; }
373
+ }
374
+ @keyframes hab-pulse {
375
+ 0%, 100% { opacity: 1; transform: scale(1); }
376
+ 50% { opacity: 0.5; transform: scale(1.2); }
377
+ }
378
+ `;
379
+ document.head.appendChild(style);
380
+ }
381
+
382
+ document.body.appendChild(indicator);
383
+ }, operation);
384
+ } catch (error) {
385
+ // Silently fail if page is not ready
386
+ }
387
+ }
388
+
389
+ async hideOperationIndicator(): Promise<void> {
390
+ if (!this.page || !this.options.headed) return;
391
+
392
+ try {
393
+ await this.page.evaluate(() => {
394
+ const indicator = document.getElementById("hab-operation-indicator");
395
+ if (indicator) {
396
+ indicator.style.animation = "hab-slide-in 0.3s ease-out reverse";
397
+ setTimeout(() => indicator.remove(), 300);
398
+ }
399
+ });
400
+ } catch (error) {
401
+ // Silently fail
402
+ }
403
+ }
404
+
405
+ async getCurrentUrl(): Promise<string> {
406
+ const page = await this.getPage();
407
+ return page.url();
408
+ }
409
+
410
+ async getTitle(): Promise<string> {
411
+ const page = await this.getPage();
412
+ return page.title();
413
+ }
414
+ }
@@ -0,0 +1,53 @@
1
+ import { copyFileSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * 同步系统 Chrome Profile 数据到 hab session
7
+ * 只复制登录凭证和 Cookies,不复制扩展(避免冲突)
8
+ */
9
+ export function syncChromeData(targetUserDataDir: string): void {
10
+ try {
11
+ const systemChromeProfile = join(
12
+ homedir(),
13
+ "Library/Application Support/Google/Chrome/Default",
14
+ );
15
+
16
+ if (!existsSync(systemChromeProfile)) {
17
+ console.log("System Chrome profile not found, skipping sync");
18
+ return;
19
+ }
20
+
21
+ // 需要同步的文件列表
22
+ const filesToSync = [
23
+ "Cookies", // Cookie 数据
24
+ "Login Data", // 登录凭证
25
+ "Preferences", // Chrome 偏好设置
26
+ "Web Data", // 表单自动填充数据
27
+ "Network", // 网络相关数据
28
+ "Local Storage", // LocalStorage 数据
29
+ ];
30
+
31
+ let syncedCount = 0;
32
+
33
+ for (const fileName of filesToSync) {
34
+ const sourcePath = join(systemChromeProfile, fileName);
35
+ const targetPath = join(targetUserDataDir, fileName);
36
+
37
+ if (existsSync(sourcePath)) {
38
+ try {
39
+ copyFileSync(sourcePath, targetPath);
40
+ syncedCount++;
41
+ } catch (error) {
42
+ // 某些文件可能被锁定,跳过即可
43
+ console.log(`Skipped ${fileName} (in use)`);
44
+ }
45
+ }
46
+ }
47
+
48
+ console.log(`Synced ${syncedCount}/${filesToSync.length} files from system Chrome profile`);
49
+ } catch (error) {
50
+ console.error("Error syncing Chrome data:", error);
51
+ // 不抛出错误,允许继续启动
52
+ }
53
+ }