veil-browser 0.4.0 → 0.4.1

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/README.md CHANGED
@@ -80,6 +80,36 @@ veil shot page.png
80
80
 
81
81
  ---
82
82
 
83
+ ## Browser Selection
84
+
85
+ veil can use Playwright's bundled Chromium, an installed Chrome-compatible browser, or an already-running browser exposed over CDP.
86
+
87
+ ```bash
88
+ # Installed Google Chrome
89
+ veil login reddit --browser chrome
90
+
91
+ # Installed Dia
92
+ veil login reddit --browser dia
93
+
94
+ # Attach to a manually started browser over CDP
95
+ veil login reddit --cdp-url http://127.0.0.1:9222
96
+
97
+ # Launch a dedicated persistent automation profile
98
+ veil login reddit --browser chrome --user-data-dir "$HOME/.veil/chrome-profile"
99
+ ```
100
+
101
+ Supported shared browser flags:
102
+
103
+ - `--browser playwright|chrome|dia`
104
+ - `--browser-path /absolute/path/to/browser`
105
+ - `--cdp-url http://127.0.0.1:9222`
106
+ - `--user-data-dir /path/to/profile`
107
+ - `--timeout-ms 45000`
108
+
109
+ Use a dedicated automation profile for `--user-data-dir`. Do not point veil at your live default Chrome profile while that browser is open.
110
+
111
+ ---
112
+
83
113
  ## All Commands
84
114
 
85
115
  ### Session
@@ -89,6 +119,7 @@ veil shot page.png
89
119
  | `veil open <platform>` | Restore session and navigate to platform home |
90
120
  | `veil sessions` | List saved sessions |
91
121
  | `veil close` | Close browser |
122
+ | `veil serve` | Start the Streamable HTTP MCP server |
92
123
 
93
124
  **Supported platforms:** `x`, `linkedin`, `reddit`, `bluesky` (or any URL)
94
125
 
@@ -140,6 +171,45 @@ veil shot page.png
140
171
 
141
172
  ---
142
173
 
174
+ ## Remote MCP Server for Claude
175
+
176
+ veil now includes a real Streamable HTTP MCP server built on the official MCP SDK.
177
+
178
+ Local HTTP example:
179
+
180
+ ```bash
181
+ veil serve --host 127.0.0.1 --port 3456
182
+ ```
183
+
184
+ Direct HTTPS example:
185
+
186
+ ```bash
187
+ veil serve \
188
+ --host 0.0.0.0 \
189
+ --port 3443 \
190
+ --allowed-hosts your-domain.example \
191
+ --https-cert /etc/ssl/your-domain/fullchain.pem \
192
+ --https-key /etc/ssl/your-domain/privkey.pem
193
+ ```
194
+
195
+ Important deployment notes:
196
+
197
+ - Claude needs a reachable `https://` endpoint with a valid certificate. A localhost server or self-signed certificate is useful for testing, but not for production Claude integrations.
198
+ - The MCP endpoint is `POST /mcp`.
199
+ - Health checks are available at `GET /healthz`.
200
+ - When binding to `0.0.0.0` or another non-localhost interface, set `--allowed-hosts` to the real public hostname to keep host-header validation in place.
201
+ - You can also terminate TLS in a reverse proxy or tunnel and forward plain HTTP to `veil serve`.
202
+ - Sessions are still created separately with `veil login <platform>`. The MCP server reuses those saved sessions when tools specify a `platform`.
203
+
204
+ Smoke-test the built server locally:
205
+
206
+ ```bash
207
+ npm run build
208
+ npm run test:mcp
209
+ ```
210
+
211
+ ---
212
+
143
213
  ## For AI Agents (OpenClaw / MCP)
144
214
 
145
215
  veil ships with a `SKILL.md` that teaches OpenClaw exactly how to use it — all commands, all platform selectors, and complete task sequences.
package/dist/browser.d.ts CHANGED
@@ -1,12 +1,30 @@
1
1
  import { Browser, BrowserContext, Page } from 'playwright';
2
- export declare function ensureBrowser(opts?: {
2
+ export interface BrowserLaunchOptions {
3
3
  headed?: boolean;
4
4
  platform?: string;
5
- }): Promise<{
5
+ browser?: string;
6
+ browserPath?: string;
7
+ cdpUrl?: string;
8
+ userDataDir?: string;
9
+ timeoutMs?: number | string;
10
+ }
11
+ /**
12
+ * Ensure a browser session is available, creating or attaching as needed.
13
+ */
14
+ export declare function ensureBrowser(opts?: BrowserLaunchOptions): Promise<{
6
15
  browser: Browser;
7
16
  context: BrowserContext;
8
17
  page: Page;
9
18
  }>;
19
+ /**
20
+ * Return the current live page if the browser is still open.
21
+ */
10
22
  export declare function getPage(): Promise<Page | null>;
11
- export declare function closeBrowser(_platform?: string): Promise<void>;
23
+ /**
24
+ * Persist the current storage state when requested and close veil-owned resources.
25
+ */
26
+ export declare function closeBrowser(platform?: string): Promise<void>;
27
+ /**
28
+ * Sleep for a short human-like randomized delay.
29
+ */
12
30
  export declare function humanDelay(min?: number, max?: number): Promise<void>;
package/dist/browser.js CHANGED
@@ -1,40 +1,159 @@
1
+ import { constants as fsConstants, promises as fs } from 'fs';
1
2
  import { chromium } from 'playwright';
2
- import { promises as fs } from 'fs';
3
- import { homedir } from 'os';
4
- import { join } from 'path';
5
- import { execSync } from 'child_process';
6
- import { loadSession } from './session.js';
7
- const VEIL_DIR = join(homedir(), '.veil');
3
+ import { resolve } from 'path';
4
+ import { loadSession, saveSession } from './session.js';
8
5
  const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36';
9
- // Detect system Chrome installation
10
- function findSystemChrome() {
11
- const paths = [
12
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
13
- '/usr/bin/google-chrome',
14
- '/usr/bin/chromium',
15
- 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
16
- 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
17
- ];
18
- for (const path of paths) {
19
- try {
20
- execSync(`test -x "${path}"`, { stdio: 'ignore' });
21
- return path;
22
- }
23
- catch { }
24
- }
25
- return null;
26
- }
6
+ const DEFAULT_TIMEOUT_MS = 30000;
7
+ const KNOWN_BROWSERS = {
8
+ playwright: {},
9
+ chrome: {
10
+ executablePaths: [
11
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
12
+ '/usr/bin/google-chrome',
13
+ '/usr/bin/chromium',
14
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
15
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
16
+ ],
17
+ channel: 'chrome',
18
+ },
19
+ dia: {
20
+ executablePaths: [
21
+ '/Applications/Dia.app/Contents/MacOS/Dia',
22
+ ],
23
+ },
24
+ };
27
25
  let _browser = null;
28
26
  let _context = null;
29
27
  let _page = null;
30
- export async function ensureBrowser(opts = {}) {
31
- if (_browser?.isConnected() && _page && !_page.isClosed()) {
32
- return { browser: _browser, context: _context, page: _page };
28
+ let _closeTarget = 'browser';
29
+ let _exitHandlerRegistered = false;
30
+ /**
31
+ * Resolve a CLI option or environment variable override.
32
+ */
33
+ function resolveOption(value, envKey) {
34
+ if (value !== undefined)
35
+ return String(value);
36
+ return process.env[envKey];
37
+ }
38
+ /**
39
+ * Normalize a path-like option to an absolute path.
40
+ */
41
+ function normalizePath(value) {
42
+ if (!value)
43
+ return undefined;
44
+ return resolve(value);
45
+ }
46
+ /**
47
+ * Validate and normalize the requested browser launch mode.
48
+ */
49
+ async function resolveBrowserOptions(opts) {
50
+ const browserName = (resolveOption(opts.browser, 'VEIL_BROWSER') ?? 'playwright').toLowerCase();
51
+ const browserPath = normalizePath(resolveOption(opts.browserPath, 'VEIL_BROWSER_PATH'));
52
+ const cdpUrl = resolveOption(opts.cdpUrl, 'VEIL_CDP_URL');
53
+ const userDataDir = normalizePath(resolveOption(opts.userDataDir, 'VEIL_USER_DATA_DIR'));
54
+ const timeoutMs = parseTimeout(resolveOption(opts.timeoutMs, 'VEIL_BROWSER_TIMEOUT_MS'));
55
+ if (!isKnownBrowser(browserName) && !browserPath) {
56
+ throw new Error(`Unsupported browser "${browserName}". Use one of: ${Object.keys(KNOWN_BROWSERS).join(', ')} or pass --browser-path.`);
57
+ }
58
+ if (cdpUrl && userDataDir) {
59
+ throw new Error('Use either --cdp-url or --user-data-dir, not both.');
60
+ }
61
+ if (cdpUrl)
62
+ validateCdpUrl(cdpUrl);
63
+ if (browserPath)
64
+ await ensureExecutable(browserPath);
65
+ if (userDataDir)
66
+ await ensureDirectory(userDataDir);
67
+ return {
68
+ browser: (isKnownBrowser(browserName) ? browserName : 'playwright'),
69
+ browserPath,
70
+ cdpUrl,
71
+ userDataDir,
72
+ headed: !!opts.headed,
73
+ platform: opts.platform,
74
+ timeoutMs,
75
+ };
76
+ }
77
+ /**
78
+ * Check whether the requested browser name is one of veil's built-in targets.
79
+ */
80
+ function isKnownBrowser(value) {
81
+ return value === 'playwright' || value === 'chrome' || value === 'dia';
82
+ }
83
+ /**
84
+ * Parse a timeout input into a positive millisecond value.
85
+ */
86
+ function parseTimeout(value) {
87
+ if (!value)
88
+ return DEFAULT_TIMEOUT_MS;
89
+ const parsed = Number.parseInt(value, 10);
90
+ if (!Number.isFinite(parsed) || parsed <= 0) {
91
+ throw new Error(`Invalid timeout "${value}". Expected a positive integer in milliseconds.`);
92
+ }
93
+ return parsed;
94
+ }
95
+ /**
96
+ * Validate that the requested CDP endpoint is well-formed.
97
+ */
98
+ function validateCdpUrl(url) {
99
+ let parsed;
100
+ try {
101
+ parsed = new URL(url);
102
+ }
103
+ catch {
104
+ throw new Error(`Invalid CDP URL "${url}". Expected http(s)://host:port or ws(s)://...`);
105
+ }
106
+ const protocols = new Set(['http:', 'https:', 'ws:', 'wss:']);
107
+ if (!protocols.has(parsed.protocol)) {
108
+ throw new Error(`Unsupported CDP protocol "${parsed.protocol}". Use http, https, ws, or wss.`);
33
109
  }
34
- const executablePath = findSystemChrome();
35
- const browser = await chromium.launch({
36
- headless: !opts.headed,
37
- executablePath: executablePath || undefined, // Use system Chrome if found, else fallback
110
+ }
111
+ /**
112
+ * Verify that the requested browser executable exists and is runnable.
113
+ */
114
+ async function ensureExecutable(pathValue) {
115
+ try {
116
+ await fs.access(pathValue, fsConstants.X_OK);
117
+ }
118
+ catch {
119
+ throw new Error(`Browser executable is not accessible: ${pathValue}`);
120
+ }
121
+ }
122
+ /**
123
+ * Verify that the requested browser profile directory exists and is writable.
124
+ */
125
+ async function ensureDirectory(pathValue) {
126
+ await fs.mkdir(pathValue, { recursive: true });
127
+ try {
128
+ await fs.access(pathValue, fsConstants.R_OK | fsConstants.W_OK);
129
+ }
130
+ catch {
131
+ throw new Error(`Browser profile directory is not readable and writable: ${pathValue}`);
132
+ }
133
+ }
134
+ /**
135
+ * Locate a known browser executable on the current machine.
136
+ */
137
+ async function resolveExecutablePath(browser) {
138
+ const candidates = KNOWN_BROWSERS[browser].executablePaths ?? [];
139
+ for (const candidate of candidates) {
140
+ try {
141
+ await fs.access(candidate, fsConstants.X_OK);
142
+ return candidate;
143
+ }
144
+ catch {
145
+ // Try the next configured candidate.
146
+ }
147
+ }
148
+ return undefined;
149
+ }
150
+ /**
151
+ * Build Playwright launch options for an ephemeral browser instance.
152
+ */
153
+ async function buildLaunchOptions(config) {
154
+ const options = {
155
+ headless: !config.headed,
156
+ timeout: config.timeoutMs,
38
157
  args: [
39
158
  '--disable-blink-features=AutomationControlled',
40
159
  '--no-sandbox',
@@ -45,57 +164,198 @@ export async function ensureBrowser(opts = {}) {
45
164
  '--no-first-run',
46
165
  '--no-default-browser-check',
47
166
  '--disable-default-apps',
48
- '--disable-extensions',
49
167
  ],
50
- });
51
- const context = await browser.newContext({
168
+ };
169
+ if (config.browserPath) {
170
+ options.executablePath = config.browserPath;
171
+ return options;
172
+ }
173
+ const executablePath = await resolveExecutablePath(config.browser);
174
+ if (executablePath) {
175
+ options.executablePath = executablePath;
176
+ return options;
177
+ }
178
+ const channel = KNOWN_BROWSERS[config.browser].channel;
179
+ if (channel) {
180
+ options.channel = channel;
181
+ return options;
182
+ }
183
+ if (config.browser !== 'playwright') {
184
+ throw new Error(`Could not locate a ${config.browser} executable. Install it or pass --browser-path.`);
185
+ }
186
+ return options;
187
+ }
188
+ /**
189
+ * Build new-context options, restoring saved storage state when available.
190
+ */
191
+ function buildContextOptions(storageState) {
192
+ const options = {
52
193
  viewport: { width: 1280, height: 800 },
53
194
  userAgent: UA,
54
195
  locale: 'en-US',
55
196
  timezoneId: 'America/New_York',
56
197
  extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
57
198
  ignoreHTTPSErrors: true,
58
- });
199
+ };
200
+ if (storageState) {
201
+ options.storageState = storageState;
202
+ }
203
+ return options;
204
+ }
205
+ /**
206
+ * Apply veil's anti-detection browser shims to a browser context.
207
+ */
208
+ async function applyStealthContext(context) {
59
209
  await context.addInitScript(() => {
60
- // Spoof all automation detection vectors
61
210
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
62
211
  Object.defineProperty(navigator, 'chromeapp', { get: () => undefined });
63
212
  window.chrome = { runtime: {} };
64
213
  Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
65
214
  Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
66
- // Spoof permissions
67
- window.navigator.permissions = {
68
- query: () => Promise.resolve({ state: Notification.permission })
215
+ const permissionShim = {
216
+ query: (_permissionDesc) => Promise.resolve({ state: Notification.permission }),
69
217
  };
70
- // Override toString on navigator to hide automation
218
+ Object.defineProperty(window.navigator, 'permissions', {
219
+ configurable: true,
220
+ value: permissionShim,
221
+ });
71
222
  const originalToString = Function.prototype.toString;
72
- Function.prototype.toString = function () {
73
- if (this === window.navigator.permissions.query) {
223
+ Function.prototype.toString = function toString() {
224
+ if (this === permissionShim.query) {
74
225
  return 'function query() { [native code] }';
75
226
  }
76
227
  return originalToString.call(this);
77
228
  };
78
229
  });
79
- const session = await loadSession(opts.platform ?? 'default').catch(() => null);
80
- if (session?.cookies?.length)
81
- await context.addCookies(session.cookies).catch(() => { });
230
+ }
231
+ /**
232
+ * Open a fresh browser instance for one-shot veil operations.
233
+ */
234
+ async function launchEphemeralBrowser(config, storageState) {
235
+ const browser = await chromium.launch(await buildLaunchOptions(config));
236
+ const context = await browser.newContext(buildContextOptions(storageState));
237
+ await applyStealthContext(context);
82
238
  const page = await context.newPage();
83
239
  _browser = browser;
84
240
  _context = context;
85
241
  _page = page;
86
- await fs.mkdir(VEIL_DIR, { recursive: true });
87
- process.once('exit', () => { browser.close().catch(() => { }); });
242
+ _closeTarget = 'browser';
243
+ return { browser, context, page };
244
+ }
245
+ /**
246
+ * Open a persistent browser profile backed by a user-data directory.
247
+ */
248
+ async function launchPersistentBrowser(config) {
249
+ if (!config.userDataDir) {
250
+ throw new Error('Persistent browser launch requires a user data directory.');
251
+ }
252
+ const persistentOptions = {
253
+ ...(await buildLaunchOptions(config)),
254
+ ...buildContextOptions(null),
255
+ };
256
+ const context = await chromium.launchPersistentContext(config.userDataDir, persistentOptions);
257
+ await applyStealthContext(context);
258
+ const browser = context.browser();
259
+ if (!browser) {
260
+ await context.close();
261
+ throw new Error('Persistent browser launch succeeded but no browser handle was returned.');
262
+ }
263
+ const page = context.pages()[0] ?? await context.newPage();
264
+ _browser = browser;
265
+ _context = context;
266
+ _page = page;
267
+ _closeTarget = 'context';
268
+ return { browser, context, page };
269
+ }
270
+ /**
271
+ * Attach to a manually launched Chromium browser via CDP.
272
+ */
273
+ async function connectToExistingBrowser(config) {
274
+ if (!config.cdpUrl) {
275
+ throw new Error('CDP browser attach requires a CDP URL.');
276
+ }
277
+ const browser = await chromium.connectOverCDP(config.cdpUrl, { timeout: config.timeoutMs });
278
+ const context = browser.contexts()[0];
279
+ if (!context) {
280
+ await browser.close().catch(() => { });
281
+ throw new Error(`No browser context is available at ${config.cdpUrl}. Open a normal tab in the target browser and try again.`);
282
+ }
283
+ await applyStealthContext(context);
284
+ const page = context.pages()[0] ?? await context.newPage();
285
+ _browser = browser;
286
+ _context = context;
287
+ _page = page;
288
+ _closeTarget = 'none';
88
289
  return { browser, context, page };
89
290
  }
291
+ /**
292
+ * Register a single process-exit hook that respects veil's close policy.
293
+ */
294
+ function registerExitHandler() {
295
+ if (_exitHandlerRegistered)
296
+ return;
297
+ _exitHandlerRegistered = true;
298
+ process.once('exit', () => {
299
+ void closeBrowser();
300
+ });
301
+ }
302
+ /**
303
+ * Ensure a browser session is available, creating or attaching as needed.
304
+ */
305
+ export async function ensureBrowser(opts = {}) {
306
+ if (_browser?.isConnected() && _context && _page && !_page.isClosed()) {
307
+ return { browser: _browser, context: _context, page: _page };
308
+ }
309
+ const config = await resolveBrowserOptions(opts);
310
+ const storageState = config.platform ? await loadSession(config.platform).catch(() => null) : null;
311
+ registerExitHandler();
312
+ if (config.cdpUrl) {
313
+ return connectToExistingBrowser(config);
314
+ }
315
+ if (config.userDataDir) {
316
+ return launchPersistentBrowser(config);
317
+ }
318
+ return launchEphemeralBrowser(config, storageState);
319
+ }
320
+ /**
321
+ * Return the current live page if the browser is still open.
322
+ */
90
323
  export async function getPage() {
91
- return (_page && !_page.isClosed()) ? _page : null;
324
+ return _page && !_page.isClosed() ? _page : null;
92
325
  }
93
- export async function closeBrowser(_platform) {
94
- await _browser?.close().catch(() => { });
95
- _browser = null;
96
- _context = null;
97
- _page = null;
326
+ /**
327
+ * Persist the current storage state when requested and close veil-owned resources.
328
+ */
329
+ export async function closeBrowser(platform) {
330
+ if (_context && platform) {
331
+ try {
332
+ await saveSession(platform, await _context.storageState());
333
+ }
334
+ catch {
335
+ // Do not let session save errors block cleanup.
336
+ }
337
+ }
338
+ try {
339
+ if (_closeTarget === 'context' && _context) {
340
+ await _context.close();
341
+ }
342
+ else if (_closeTarget === 'browser' && _browser) {
343
+ await _browser.close();
344
+ }
345
+ }
346
+ catch {
347
+ // Ignore cleanup failures; the process may already be tearing down.
348
+ }
349
+ finally {
350
+ _browser = null;
351
+ _context = null;
352
+ _page = null;
353
+ _closeTarget = 'browser';
354
+ }
98
355
  }
356
+ /**
357
+ * Sleep for a short human-like randomized delay.
358
+ */
99
359
  export function humanDelay(min = 400, max = 900) {
100
- return new Promise(r => setTimeout(r, Math.floor(Math.random() * (max - min) + min)));
360
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, Math.floor(Math.random() * (max - min) + min)));
101
361
  }