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.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/package.json +63 -0
- package/src/browser/context.ts +66 -0
- package/src/browser/manager.ts +414 -0
- package/src/browser/sync-chrome-data.ts +53 -0
- package/src/cli.ts +628 -0
- package/src/cli.ts.backup +529 -0
- package/src/commands/actions.ts +232 -0
- package/src/commands/advanced.ts +252 -0
- package/src/commands/config.ts +44 -0
- package/src/commands/getters.ts +110 -0
- package/src/commands/info.ts +195 -0
- package/src/commands/navigation.ts +50 -0
- package/src/commands/session.ts +83 -0
- package/src/daemon/browser-pool.ts +65 -0
- package/src/daemon/client.ts +128 -0
- package/src/daemon/main.ts +200 -0
- package/src/daemon/server.ts +562 -0
- package/src/session/manager.ts +110 -0
- package/src/session/store.ts +172 -0
- package/src/snapshot/accessibility.ts +182 -0
- package/src/snapshot/dom-extractor.ts +220 -0
- package/src/snapshot/formatter.ts +115 -0
- package/src/snapshot/reference-store.ts +97 -0
- package/src/utils/config.ts +183 -0
- package/src/utils/errors.ts +121 -0
- package/src/utils/logger.ts +12 -0
- package/src/utils/selector.ts +23 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Session } from "../session/store";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stores and retrieves element reference mappings for a session
|
|
8
|
+
* Maps @eN references to actual CSS selectors
|
|
9
|
+
*/
|
|
10
|
+
export class ReferenceStore {
|
|
11
|
+
private sessionDir: string;
|
|
12
|
+
private mappingsFile: string;
|
|
13
|
+
private mappings: Map<string, string> = new Map();
|
|
14
|
+
|
|
15
|
+
constructor(session: Session) {
|
|
16
|
+
this.sessionDir = join(session.userDataDir, "..");
|
|
17
|
+
this.mappingsFile = join(this.sessionDir, "element-mappings.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load mappings from disk
|
|
22
|
+
*/
|
|
23
|
+
async load(): Promise<void> {
|
|
24
|
+
if (!existsSync(this.mappingsFile)) {
|
|
25
|
+
this.mappings.clear();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const content = await readFile(this.mappingsFile, "utf-8");
|
|
31
|
+
const data = JSON.parse(content);
|
|
32
|
+
this.mappings = new Map(Object.entries(data));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error("Failed to load element mappings:", error);
|
|
35
|
+
this.mappings.clear();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Save mappings to disk
|
|
41
|
+
*/
|
|
42
|
+
async save(): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
// Ensure directory exists
|
|
45
|
+
if (!existsSync(this.sessionDir)) {
|
|
46
|
+
await mkdir(this.sessionDir, { recursive: true, mode: 0o700 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const data = Object.fromEntries(this.mappings);
|
|
50
|
+
await writeFile(this.mappingsFile, JSON.stringify(data, null, 2), "utf-8");
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error("Failed to save element mappings:", error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Store a new mapping
|
|
58
|
+
*/
|
|
59
|
+
set(ref: string, selector: string): void {
|
|
60
|
+
this.mappings.set(ref, selector);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get selector for a reference
|
|
65
|
+
*/
|
|
66
|
+
get(ref: string): string | undefined {
|
|
67
|
+
return this.mappings.get(ref);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Update all mappings at once
|
|
72
|
+
*/
|
|
73
|
+
setAll(mappings: Map<string, string>): void {
|
|
74
|
+
this.mappings = new Map(mappings);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Clear all mappings
|
|
79
|
+
*/
|
|
80
|
+
clear(): void {
|
|
81
|
+
this.mappings.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get all mappings
|
|
86
|
+
*/
|
|
87
|
+
getAll(): Map<string, string> {
|
|
88
|
+
return new Map(this.mappings);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if a reference exists
|
|
93
|
+
*/
|
|
94
|
+
has(ref: string): boolean {
|
|
95
|
+
return this.mappings.has(ref);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
export const ConfigSchema = z.object({
|
|
8
|
+
version: z.string(),
|
|
9
|
+
defaults: z.object({
|
|
10
|
+
session: z.string(),
|
|
11
|
+
headed: z.boolean(),
|
|
12
|
+
channel: z.enum(["chrome", "msedge", "chromium"]),
|
|
13
|
+
timeout: z.number(),
|
|
14
|
+
}),
|
|
15
|
+
sessions: z
|
|
16
|
+
.object({
|
|
17
|
+
dataDir: z.string(),
|
|
18
|
+
})
|
|
19
|
+
.optional(),
|
|
20
|
+
browser: z
|
|
21
|
+
.object({
|
|
22
|
+
args: z.array(z.string()).optional(),
|
|
23
|
+
ignoreDefaultArgs: z.array(z.string()).optional(),
|
|
24
|
+
})
|
|
25
|
+
.optional(),
|
|
26
|
+
snapshot: z
|
|
27
|
+
.object({
|
|
28
|
+
interactiveRoles: z.array(z.string()).optional(),
|
|
29
|
+
})
|
|
30
|
+
.optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
34
|
+
|
|
35
|
+
const DEFAULT_CONFIG: Config = {
|
|
36
|
+
version: "1.0",
|
|
37
|
+
defaults: {
|
|
38
|
+
session: "default",
|
|
39
|
+
headed: false,
|
|
40
|
+
channel: "chrome",
|
|
41
|
+
timeout: 30000,
|
|
42
|
+
},
|
|
43
|
+
sessions: {
|
|
44
|
+
dataDir: join(homedir(), ".hab", "sessions"),
|
|
45
|
+
},
|
|
46
|
+
browser: {
|
|
47
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
48
|
+
ignoreDefaultArgs: ["--enable-automation"],
|
|
49
|
+
},
|
|
50
|
+
snapshot: {
|
|
51
|
+
interactiveRoles: [
|
|
52
|
+
"button",
|
|
53
|
+
"link",
|
|
54
|
+
"textbox",
|
|
55
|
+
"checkbox",
|
|
56
|
+
"radio",
|
|
57
|
+
"combobox",
|
|
58
|
+
"menuitem",
|
|
59
|
+
"tab",
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function getConfigPath(): string {
|
|
65
|
+
const envPath = process.env.HAB_CONFIG;
|
|
66
|
+
if (envPath) {
|
|
67
|
+
return envPath;
|
|
68
|
+
}
|
|
69
|
+
return join(homedir(), ".hab", "config.json");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function loadConfig(): Promise<Config> {
|
|
73
|
+
const configPath = getConfigPath();
|
|
74
|
+
|
|
75
|
+
if (!existsSync(configPath)) {
|
|
76
|
+
return DEFAULT_CONFIG;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const content = await readFile(configPath, "utf-8");
|
|
81
|
+
const data = JSON.parse(content);
|
|
82
|
+
const validated = ConfigSchema.parse(data);
|
|
83
|
+
return validated;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error(`Warning: Failed to load config from ${configPath}:`, error);
|
|
86
|
+
return DEFAULT_CONFIG;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function saveConfig(config: Config): Promise<void> {
|
|
91
|
+
const configPath = getConfigPath();
|
|
92
|
+
const configDir = join(configPath, "..");
|
|
93
|
+
|
|
94
|
+
// Ensure directory exists
|
|
95
|
+
if (!existsSync(configDir)) {
|
|
96
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
100
|
+
|
|
101
|
+
// Security: Set file permissions to 0o600 (owner read/write only)
|
|
102
|
+
// Protects sensitive configuration from other users
|
|
103
|
+
const { chmod } = await import("node:fs/promises");
|
|
104
|
+
await chmod(configPath, 0o600);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function getConfigValue(key: string): Promise<unknown> {
|
|
108
|
+
const config = await loadConfig();
|
|
109
|
+
const keys = key.split(".");
|
|
110
|
+
|
|
111
|
+
let value: any = config;
|
|
112
|
+
for (const k of keys) {
|
|
113
|
+
if (value && typeof value === "object" && k in value) {
|
|
114
|
+
value = value[k];
|
|
115
|
+
} else {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Whitelist of config keys that can be modified via CLI
|
|
124
|
+
const ALLOWED_CONFIG_KEYS = [
|
|
125
|
+
"defaults.session",
|
|
126
|
+
"defaults.headed",
|
|
127
|
+
"defaults.channel",
|
|
128
|
+
"defaults.timeout",
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
// Dangerous browser arguments that should be blocked
|
|
132
|
+
const DANGEROUS_BROWSER_ARGS = [
|
|
133
|
+
"remote-debugging-port",
|
|
134
|
+
"disable-web-security",
|
|
135
|
+
"disable-site-isolation",
|
|
136
|
+
"disable-features=IsolateOrigins",
|
|
137
|
+
"disable-setuid-sandbox",
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
export async function setConfigValue(key: string, value: unknown): Promise<void> {
|
|
141
|
+
// Security: Whitelist check - only allow safe config keys
|
|
142
|
+
if (!ALLOWED_CONFIG_KEYS.includes(key)) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Config key '${key}' cannot be modified via CLI. ` +
|
|
145
|
+
`Allowed keys: ${ALLOWED_CONFIG_KEYS.join(", ")}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Security: Validate browser.args if being set
|
|
150
|
+
if (key === "browser.args" && Array.isArray(value)) {
|
|
151
|
+
for (const arg of value as string[]) {
|
|
152
|
+
for (const dangerous of DANGEROUS_BROWSER_ARGS) {
|
|
153
|
+
if (arg.toLowerCase().includes(dangerous)) {
|
|
154
|
+
throw new Error(`Dangerous browser argument blocked: ${arg}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const config = await loadConfig();
|
|
161
|
+
const keys = key.split(".");
|
|
162
|
+
|
|
163
|
+
if (keys.length === 0) {
|
|
164
|
+
throw new Error("Invalid config key");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let obj: any = config;
|
|
168
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
169
|
+
const k = keys[i];
|
|
170
|
+
if (!(k in obj) || typeof obj[k] !== "object") {
|
|
171
|
+
obj[k] = {};
|
|
172
|
+
}
|
|
173
|
+
obj = obj[k];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const lastKey = keys[keys.length - 1];
|
|
177
|
+
obj[lastKey] = value;
|
|
178
|
+
|
|
179
|
+
// Validate updated config
|
|
180
|
+
ConfigSchema.parse(config);
|
|
181
|
+
|
|
182
|
+
await saveConfig(config);
|
|
183
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export class HBAError extends Error {
|
|
2
|
+
code: string;
|
|
3
|
+
hint?: string;
|
|
4
|
+
|
|
5
|
+
constructor(message: string, code: string, hint?: string) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = this.constructor.name;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.hint = hint;
|
|
10
|
+
Error.captureStackTrace(this, this.constructor);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class SessionNotFoundError extends HBAError {
|
|
15
|
+
constructor(sessionName: string) {
|
|
16
|
+
super(
|
|
17
|
+
`Session '${sessionName}' not found`,
|
|
18
|
+
"SESSION_NOT_FOUND",
|
|
19
|
+
"Run 'hab sessions' to see available sessions",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class ElementNotFoundError extends HBAError {
|
|
25
|
+
constructor(selector: string) {
|
|
26
|
+
super(
|
|
27
|
+
`Element not found: ${selector}`,
|
|
28
|
+
"ELEMENT_NOT_FOUND",
|
|
29
|
+
"Run 'hab snapshot -i' to see available elements",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class BrowserNotRunningError extends HBAError {
|
|
35
|
+
constructor() {
|
|
36
|
+
super("Browser not running", "BROWSER_NOT_RUNNING", "Run 'hab open <url>' first");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class TimeoutError extends HBAError {
|
|
41
|
+
constructor(operation: string, timeout: number) {
|
|
42
|
+
super(
|
|
43
|
+
`Operation timed out: ${operation} (${timeout}ms)`,
|
|
44
|
+
"TIMEOUT",
|
|
45
|
+
"Increase timeout with --timeout option",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class NavigationError extends HBAError {
|
|
51
|
+
constructor(url: string, reason?: string) {
|
|
52
|
+
super(
|
|
53
|
+
`Navigation failed: ${url}${reason ? ` (${reason})` : ""}`,
|
|
54
|
+
"NAVIGATION_FAILED",
|
|
55
|
+
"Check if the URL is accessible and valid",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class SelectorError extends HBAError {
|
|
61
|
+
constructor(selector: string) {
|
|
62
|
+
super(
|
|
63
|
+
`Invalid selector: ${selector}`,
|
|
64
|
+
"INVALID_SELECTOR",
|
|
65
|
+
"Use @eN, css=..., text=..., or xpath=...",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class ConfigError extends HBAError {
|
|
71
|
+
constructor(message: string) {
|
|
72
|
+
super(message, "CONFIG_ERROR", "Check ~/.hab/config.json for syntax errors");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class PermissionError extends HBAError {
|
|
77
|
+
constructor(path: string) {
|
|
78
|
+
super(`Permission denied: ${path}`, "PERMISSION_ERROR", "Check file/directory permissions");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Format error for CLI output
|
|
84
|
+
*/
|
|
85
|
+
export function formatError(error: Error): string {
|
|
86
|
+
let output = `Error: ${error.message}`;
|
|
87
|
+
|
|
88
|
+
if (error instanceof HBAError) {
|
|
89
|
+
if (error.hint) {
|
|
90
|
+
output += `\n Hint: ${error.hint}`;
|
|
91
|
+
}
|
|
92
|
+
output += `\n Code: ${error.code}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return output;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get exit code for error type
|
|
100
|
+
*/
|
|
101
|
+
export function getExitCode(error: Error): number {
|
|
102
|
+
if (error instanceof HBAError) {
|
|
103
|
+
switch (error.code) {
|
|
104
|
+
case "INVALID_SELECTOR":
|
|
105
|
+
return 2; // Argument error
|
|
106
|
+
case "SESSION_NOT_FOUND":
|
|
107
|
+
case "CONFIG_ERROR":
|
|
108
|
+
return 3; // Session error
|
|
109
|
+
case "BROWSER_NOT_RUNNING":
|
|
110
|
+
case "NAVIGATION_FAILED":
|
|
111
|
+
return 4; // Browser error
|
|
112
|
+
case "ELEMENT_NOT_FOUND":
|
|
113
|
+
return 5; // Element error
|
|
114
|
+
case "TIMEOUT":
|
|
115
|
+
return 6; // Timeout
|
|
116
|
+
default:
|
|
117
|
+
return 1; // General error
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return 1; // General error
|
|
121
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function log(message: string, verbose = false) {
|
|
2
|
+
if (verbose) {
|
|
3
|
+
console.log(`[HBA] ${message}`);
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function error(message: string, hint?: string) {
|
|
8
|
+
console.error(`Error: ${message}`);
|
|
9
|
+
if (hint) {
|
|
10
|
+
console.error(` Hint: ${hint}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type SelectorType = "ref" | "css" | "text" | "xpath";
|
|
2
|
+
|
|
3
|
+
export interface ParsedSelector {
|
|
4
|
+
type: SelectorType;
|
|
5
|
+
value: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseSelector(selector: string): ParsedSelector {
|
|
9
|
+
if (selector.startsWith("@e")) {
|
|
10
|
+
return { type: "ref", value: selector.slice(1) };
|
|
11
|
+
}
|
|
12
|
+
if (selector.startsWith("css=")) {
|
|
13
|
+
return { type: "css", value: selector.slice(4) };
|
|
14
|
+
}
|
|
15
|
+
if (selector.startsWith("text=")) {
|
|
16
|
+
return { type: "text", value: selector.slice(5) };
|
|
17
|
+
}
|
|
18
|
+
if (selector.startsWith("xpath=")) {
|
|
19
|
+
return { type: "xpath", value: selector.slice(6) };
|
|
20
|
+
}
|
|
21
|
+
// Default to CSS
|
|
22
|
+
return { type: "css", value: selector };
|
|
23
|
+
}
|