granclaw 0.0.1-beta.24 → 0.0.1-beta.26

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.
@@ -2,26 +2,24 @@
2
2
  /**
3
3
  * stealth.ts
4
4
  *
5
- * Central place where we build the agent-browser launch flags that hide
6
- * automation fingerprints. Two levers, both applied when available:
5
+ * Two mechanisms for hiding automation fingerprints:
7
6
  *
8
- * 1. --extension <path> loads the GranClaw stealth Chrome extension
9
- * which patches navigator.webdriver and friends
10
- * at document_start, before any page script runs.
7
+ * 1. stealthArgv() — argv fragment for `agent-browser` launch.
8
+ * Adds --executable-path when a real Chrome/Chromium
9
+ * binary is available (real Chrome is less flagged
10
+ * than the Playwright-bundled build).
11
11
  *
12
- * 2. --executable-path <path> points agent-browser at the user's real
13
- * Google Chrome install (macOS/Linux/Windows)
14
- * instead of the Chromium it downloads itself.
15
- * Real Chrome is less flagged by Google/Cloudflare
16
- * than Chromium for generic fingerprint reasons.
12
+ * 2. injectStealthViaCdp() — connects to the running browser via CDP and
13
+ * calls Page.addScriptToEvaluateOnNewDocument with
14
+ * the stealth.js patches. Also fires Runtime.evaluate
15
+ * on the current page so the patches apply immediately,
16
+ * not just on the next navigation.
17
+ * Works in both headless (Docker) and headed (local)
18
+ * mode — no display or extension loader required.
17
19
  *
18
- * Both are best-effort: if the extension directory is missing or Chrome isn't
19
- * installed we simply skip that flag. Callers always get back a flat argv
20
- * fragment they can spread into their existing spawn line.
21
- *
22
- * Override hooks for users who want to pin a specific Chrome:
23
- * GRANCLAW_STEALTH_DISABLED=1 → no flags at all
24
- * GRANCLAW_CHROME_PATH=/abs/path/chrome → force a specific binary
20
+ * Override hooks:
21
+ * GRANCLAW_STEALTH_DISABLED=1 → both mechanisms are no-ops
22
+ * GRANCLAW_CHROME_PATH=/abs/path/chrome force a specific binary for --executable-path
25
23
  */
26
24
  var __importDefault = (this && this.__importDefault) || function (mod) {
27
25
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -29,19 +27,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
29
27
  Object.defineProperty(exports, "__esModule", { value: true });
30
28
  exports.STEALTH_EXTENSION_DIR = void 0;
31
29
  exports.stealthArgv = stealthArgv;
30
+ exports.injectStealthViaCdp = injectStealthViaCdp;
31
+ exports.prewarmStealthDaemon = prewarmStealthDaemon;
32
32
  exports.__resetStealthCacheForTests = __resetStealthCacheForTests;
33
33
  const fs_1 = __importDefault(require("fs"));
34
34
  const path_1 = __importDefault(require("path"));
35
+ const http_1 = __importDefault(require("http"));
36
+ const child_process_1 = require("child_process");
37
+ const util_1 = require("util");
38
+ const ws_1 = require("ws");
39
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
40
+ // ── Extension dir (source of stealth.js) ──────────────────────────────────────
35
41
  /**
36
42
  * Resolve the stealth-extension directory across three layouts:
37
43
  *
38
44
  * - dev (tsx src): packages/backend/src/browser/ → ../../assets/stealth-extension
39
45
  * - backend standalone: packages/backend/dist/browser/ → ../../assets/stealth-extension
40
- * - cli bundled publish: packages/cli/dist/backend/browser/ → ../../assets/stealth-extension
41
- * OR ../assets/stealth-extension
42
- *
43
- * We probe both candidates and take the first that exists so the helper is
44
- * layout-agnostic. Callers fall back gracefully when none resolve.
46
+ * - cli bundled publish: packages/cli/dist/backend/browser/ → ../assets/stealth-extension
45
47
  */
46
48
  function resolveExtensionDir() {
47
49
  const candidates = [
@@ -56,17 +58,14 @@ function resolveExtensionDir() {
56
58
  }
57
59
  const STEALTH_EXTENSION_DIR = resolveExtensionDir();
58
60
  exports.STEALTH_EXTENSION_DIR = STEALTH_EXTENSION_DIR;
59
- /**
60
- * Probe a list of well-known Chrome install paths and return the first that
61
- * exists. Resolved once per process to keep the per-launch cost at zero.
62
- */
61
+ // ── Chrome binary detection ───────────────────────────────────────────────────
63
62
  let cachedChromePath;
64
63
  function detectChromePath() {
65
64
  if (cachedChromePath !== undefined)
66
65
  return cachedChromePath;
67
- // GRANCLAW_CHROME_PATH takes priority; fall back to the agent-browser env
68
- // var so Docker containers (which set AGENT_BROWSER_CHROME_PATH=/usr/bin/chromium)
69
- // automatically point stealth at the same binary agent-browser will use.
66
+ // GRANCLAW_CHROME_PATH takes priority; fall back to AGENT_BROWSER_CHROME_PATH
67
+ // so Docker containers (AGENT_BROWSER_CHROME_PATH=/usr/bin/chromium) automatically
68
+ // use the same binary that agent-browser itself is configured to use.
70
69
  const override = process.env.GRANCLAW_CHROME_PATH || process.env.AGENT_BROWSER_CHROME_PATH;
71
70
  if (override && fs_1.default.existsSync(override)) {
72
71
  cachedChromePath = override;
@@ -106,35 +105,174 @@ function detectChromePath() {
106
105
  cachedChromePath = null;
107
106
  return null;
108
107
  }
108
+ // ── argv builder ──────────────────────────────────────────────────────────────
109
109
  /**
110
- * Return the argv fragment to pass to `agent-browser` on the command that
111
- * boots the daemon. agent-browser ignores launch flags on subsequent calls
112
- * to an already-running daemon, so these must land on the first command.
113
- *
114
- * The caller splices the result into its own argv — e.g.:
115
- *
116
- * const argv = ['--session', agentId, ...stealthArgv(), 'open', url];
110
+ * Return the argv fragment for the `agent-browser` command that boots the
111
+ * daemon. Only --executable-path is emitted here; the stealth JS patches are
112
+ * applied separately via injectStealthViaCdp() after the session is up.
117
113
  */
118
114
  function stealthArgv() {
119
- // Re-enabled 2026-04-14: UA/deviceMemory patches added, AGENT_BROWSER_CHROME_PATH
120
- // wired up so Docker containers automatically use the in-place Chromium.
121
- // Set GRANCLAW_STEALTH_DISABLED=1 to opt out entirely.
122
115
  if (process.env.GRANCLAW_STEALTH_DISABLED === '1')
123
116
  return [];
124
117
  const argv = [];
125
- if (STEALTH_EXTENSION_DIR) {
126
- argv.push('--extension', STEALTH_EXTENSION_DIR);
127
- }
128
118
  const chrome = detectChromePath();
129
119
  if (chrome) {
130
120
  argv.push('--executable-path', chrome);
131
121
  }
132
122
  return argv;
133
123
  }
124
+ // ── CDP stealth injection ─────────────────────────────────────────────────────
125
+ /**
126
+ * Poll for the browser-level CDP WebSocket URL.
127
+ *
128
+ * @param retries How many attempts to make (default 8, ~2.4 s). Pass 1 for
129
+ * a fast single-shot check used by the background watcher.
130
+ */
131
+ async function discoverCdpUrl(sessionId, workspaceDir, retries = 8) {
132
+ const bin = process.env.AGENT_BROWSER_BIN ?? 'agent-browser';
133
+ for (let i = 0; i < retries; i++) {
134
+ try {
135
+ const { stdout } = await execFileAsync(bin, ['--session', sessionId, 'get', 'cdp-url'], {
136
+ cwd: workspaceDir,
137
+ timeout: 2000,
138
+ });
139
+ const url = stdout.trim();
140
+ if (url.startsWith('ws://') || url.startsWith('wss://'))
141
+ return url;
142
+ }
143
+ catch { /* daemon not ready yet */ }
144
+ if (i < retries - 1)
145
+ await new Promise((r) => setTimeout(r, 300));
146
+ }
147
+ return null;
148
+ }
149
+ /**
150
+ * Fetch all page targets from the browser's /json/list endpoint.
151
+ */
152
+ async function fetchPageTargets(browserCdpUrl) {
153
+ return new Promise((resolve) => {
154
+ const match = /^wss?:\/\/([^/]+)\//.exec(browserCdpUrl);
155
+ if (!match) {
156
+ resolve([]);
157
+ return;
158
+ }
159
+ const host = match[1];
160
+ const req = http_1.default.get(`http://${host}/json/list`, { timeout: 3000 }, (res) => {
161
+ let body = '';
162
+ res.on('data', (c) => { body += c.toString(); });
163
+ res.on('end', () => {
164
+ try {
165
+ const targets = JSON.parse(body);
166
+ resolve(targets
167
+ .filter((t) => t.type === 'page' && t.webSocketDebuggerUrl)
168
+ .map((t) => ({ webSocketDebuggerUrl: t.webSocketDebuggerUrl })));
169
+ }
170
+ catch {
171
+ resolve([]);
172
+ }
173
+ });
174
+ });
175
+ req.on('error', () => resolve([]));
176
+ req.on('timeout', () => { req.destroy(); resolve([]); });
177
+ });
178
+ }
134
179
  /**
135
- * Test-only hook to reset the Chrome-path cache between tests.
136
- * @internal
180
+ * Open a CDP WebSocket to a single page target and:
181
+ * 1. Register the stealth script for all future navigations via
182
+ * Page.addScriptToEvaluateOnNewDocument.
183
+ * 2. Run it immediately on the current document via Runtime.evaluate so
184
+ * patches are live without a reload.
137
185
  */
186
+ function injectIntoPage(wsUrl, script) {
187
+ return new Promise((resolve) => {
188
+ const cleanup = (ws) => { try {
189
+ ws.close();
190
+ }
191
+ catch { /* ignore */ } resolve(); };
192
+ const timer = setTimeout(() => cleanup(ws), 5000);
193
+ const ws = new ws_1.WebSocket(wsUrl);
194
+ ws.on('open', () => {
195
+ ws.send(JSON.stringify({
196
+ id: 1,
197
+ method: 'Page.addScriptToEvaluateOnNewDocument',
198
+ params: { source: script },
199
+ }));
200
+ ws.send(JSON.stringify({
201
+ id: 2,
202
+ method: 'Runtime.evaluate',
203
+ params: { expression: script, returnByValue: false },
204
+ }));
205
+ // Allow a short window for Chrome to ack, then close.
206
+ setTimeout(() => { clearTimeout(timer); cleanup(ws); }, 400);
207
+ });
208
+ ws.on('error', () => { clearTimeout(timer); resolve(); });
209
+ ws.on('close', () => { clearTimeout(timer); resolve(); });
210
+ });
211
+ }
212
+ /**
213
+ * Inject the stealth script into every open page of the agent's browser
214
+ * session via CDP. Call this after the agent-browser daemon has started.
215
+ *
216
+ * Best-effort: any error is swallowed so a CDP failure never breaks the
217
+ * caller's main flow.
218
+ */
219
+ async function injectStealthViaCdp(sessionId, workspaceDir) {
220
+ if (process.env.GRANCLAW_STEALTH_DISABLED === '1')
221
+ return;
222
+ if (!STEALTH_EXTENSION_DIR)
223
+ return;
224
+ const scriptPath = path_1.default.join(STEALTH_EXTENSION_DIR, 'stealth.js');
225
+ if (!fs_1.default.existsSync(scriptPath))
226
+ return;
227
+ try {
228
+ const script = fs_1.default.readFileSync(scriptPath, 'utf-8');
229
+ const cdpUrl = await discoverCdpUrl(sessionId, workspaceDir);
230
+ if (!cdpUrl)
231
+ return;
232
+ const pages = await fetchPageTargets(cdpUrl);
233
+ await Promise.all(pages.map((p) => injectIntoPage(p.webSocketDebuggerUrl, script)));
234
+ console.log(`[stealth] injected into ${pages.length} page(s) for session "${sessionId}"`);
235
+ }
236
+ catch (err) {
237
+ console.warn(`[stealth] CDP injection failed for "${sessionId}":`, err);
238
+ }
239
+ }
240
+ // ── Daemon pre-warm ───────────────────────────────────────────────────────────
241
+ /**
242
+ * Pre-warm the agent's browser daemon and register stealth before any
243
+ * agent navigation. Call this once at agent process startup for agents
244
+ * that have the browser tool enabled.
245
+ *
246
+ * Steps:
247
+ * 1. Start the daemon with `agent-browser open about:blank` (no-op if
248
+ * already running — agent-browser reuses the existing daemon).
249
+ * 2. Inject Page.addScriptToEvaluateOnNewDocument via CDP so every
250
+ * subsequent navigation the agent makes will have stealth running
251
+ * before the page's own scripts.
252
+ *
253
+ * This is a one-time setup. If the agent later kills its daemon via
254
+ * `browser close --all`, the next daemon start won't have stealth until
255
+ * the live view relay re-attaches (which also injects stealth).
256
+ */
257
+ async function prewarmStealthDaemon(sessionId, workspaceDir) {
258
+ if (process.env.GRANCLAW_STEALTH_DISABLED === '1')
259
+ return;
260
+ try {
261
+ const bin = process.env.AGENT_BROWSER_BIN ?? 'agent-browser';
262
+ // Boot the daemon (or no-op if already running) and land on about:blank.
263
+ // Use execFileAsync with a short timeout — we don't care about the result,
264
+ // only that the daemon is now up.
265
+ await execFileAsync(bin, ['--session', sessionId, ...stealthArgv(), 'open', 'about:blank'], { cwd: workspaceDir, timeout: 15000 });
266
+ }
267
+ catch {
268
+ // Daemon failed to start (no Chrome, wrong env, etc.) — skip silently.
269
+ return;
270
+ }
271
+ // Daemon is up. Register stealth for all future navigations.
272
+ await injectStealthViaCdp(sessionId, workspaceDir);
273
+ }
274
+ // ── Test helpers ──────────────────────────────────────────────────────────────
275
+ /** @internal */
138
276
  function __resetStealthCacheForTests() {
139
277
  cachedChromePath = undefined;
140
278
  }
@@ -25,6 +25,7 @@ exports.stopAndRemoveAgent = stopAndRemoveAgent;
25
25
  const child_process_1 = require("child_process");
26
26
  const path_1 = __importDefault(require("path"));
27
27
  const config_js_1 = require("../config.js");
28
+ const stealth_js_1 = require("../browser/stealth.js");
28
29
  const secrets_vault_js_1 = require("../secrets-vault.js");
29
30
  exports.BASE_AGENT_PORT = Number(process.env.AGENT_BASE_PORT ?? 3100);
30
31
  /**
@@ -87,6 +88,13 @@ function startNewAgent(agent) {
87
88
  const managed = { config: agent, wsPort, bbPort: null, pid: child.pid };
88
89
  registry.set(agent.id, managed);
89
90
  console.log(`[orchestrator] agent "${agent.id}" started on ws port ${wsPort} (pid ${child.pid})`);
91
+ // Pre-warm the browser daemon for browser-capable agents so stealth is
92
+ // registered via Page.addScriptToEvaluateOnNewDocument before the agent's
93
+ // first navigation. Fire-and-forget — never blocks agent startup.
94
+ if (agent.allowedTools?.includes('browser')) {
95
+ const workspaceDir = path_1.default.resolve(config_js_1.REPO_ROOT, agent.workspaceDir);
96
+ void (0, stealth_js_1.prewarmStealthDaemon)(agent.id, workspaceDir);
97
+ }
90
98
  return managed;
91
99
  }
92
100
  /**
@@ -39,6 +39,9 @@ const http_1 = __importDefault(require("http"));
39
39
  const ws_1 = require("ws");
40
40
  const child_process_1 = require("child_process");
41
41
  const util_1 = require("util");
42
+ const stealth_js_1 = require("../browser/stealth.js");
43
+ const fs_1 = __importDefault(require("fs"));
44
+ const path_1 = __importDefault(require("path"));
42
45
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
43
46
  const TAB_POLL_INTERVAL_MS = 2000;
44
47
  /**
@@ -261,6 +264,57 @@ function pickCdpPageForTab(pages, activeUrl) {
261
264
  const real = pages.filter((p) => !isInert(p.url));
262
265
  return real.length > 0 ? real[real.length - 1] : pages[pages.length - 1];
263
266
  }
267
+ /**
268
+ * Lazily read stealth.js once and cache it. Returns null if the extension
269
+ * directory is missing (e.g. the package wasn't installed with assets).
270
+ */
271
+ let cachedStealthScript;
272
+ function readStealthScript() {
273
+ if (cachedStealthScript !== undefined)
274
+ return cachedStealthScript;
275
+ if (!stealth_js_1.STEALTH_EXTENSION_DIR) {
276
+ cachedStealthScript = null;
277
+ return null;
278
+ }
279
+ const scriptPath = path_1.default.join(stealth_js_1.STEALTH_EXTENSION_DIR, 'stealth.js');
280
+ try {
281
+ cachedStealthScript = fs_1.default.readFileSync(scriptPath, 'utf-8');
282
+ }
283
+ catch {
284
+ cachedStealthScript = null;
285
+ }
286
+ return cachedStealthScript;
287
+ }
288
+ /**
289
+ * Inject the stealth patches on an already-open CDP WebSocket. Uses the
290
+ * stream's cdpMessageId counter so IDs stay monotonically increasing.
291
+ *
292
+ * Two commands:
293
+ * - Page.addScriptToEvaluateOnNewDocument — persists across navigations
294
+ * - Runtime.evaluate — applies to the current document immediately
295
+ *
296
+ * Best-effort: silently skipped when stealth is disabled or the script is unavailable.
297
+ */
298
+ function injectStealthOnPage(chromeWs, stream) {
299
+ if (process.env.GRANCLAW_STEALTH_DISABLED === '1')
300
+ return;
301
+ const script = readStealthScript();
302
+ if (!script)
303
+ return;
304
+ try {
305
+ chromeWs.send(JSON.stringify({
306
+ id: ++stream.cdpMessageId,
307
+ method: 'Page.addScriptToEvaluateOnNewDocument',
308
+ params: { source: script },
309
+ }));
310
+ chromeWs.send(JSON.stringify({
311
+ id: ++stream.cdpMessageId,
312
+ method: 'Runtime.evaluate',
313
+ params: { expression: script, returnByValue: false },
314
+ }));
315
+ }
316
+ catch { /* ws closed between open and send — ignore */ }
317
+ }
264
318
  /**
265
319
  * Attach to a specific CDP page target and begin screencasting. Called on
266
320
  * initial attach and again whenever we need to rebind to a new tab.
@@ -278,6 +332,9 @@ function attachPageCdp(stream, page) {
278
332
  catch { }
279
333
  return;
280
334
  }
335
+ // Inject stealth patches on every page attach — works headlessly via CDP,
336
+ // no display or extension loader needed.
337
+ injectStealthOnPage(chromeWs, stream);
281
338
  chromeWs.send(JSON.stringify({
282
339
  id: ++stream.cdpMessageId,
283
340
  method: 'Page.startScreencast',
@@ -1411,6 +1411,9 @@ function createServer() {
1411
1411
  res.status(500).json({ error: `Failed to launch browser: ${message}` });
1412
1412
  return;
1413
1413
  }
1414
+ // Inject stealth patches via CDP now that the daemon is up.
1415
+ // Fire-and-forget — a CDP failure must never block the browser-open response.
1416
+ void (0, stealth_js_1.injectStealthViaCdp)(req.params.id, workspaceDir);
1414
1417
  headedBrowsers.set(req.params.id, { url });
1415
1418
  console.log(`[browser] launched headed browser for "${req.params.id}" with profile → ${url}`);
1416
1419
  res.status(201).json({ url, profileDir });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granclaw",
3
- "version": "0.0.1-beta.24",
3
+ "version": "0.0.1-beta.26",
4
4
  "description": "A personal AI assistant you run on your own machine.",
5
5
  "license": "MIT",
6
6
  "repository": {