granclaw 0.0.1-beta.26 → 0.0.1-beta.27

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.
@@ -708,6 +708,11 @@ async function runAgent(agent, message, onChunk, options) {
708
708
  }
709
709
  if (!browserState.handle.recordingStarted) {
710
710
  await (0, session_manager_js_1.startRecording)(browserState.handle);
711
+ // Re-inject stealth into whatever daemon was just booted by
712
+ // startBrowserRecording. Page.addScriptToEvaluateOnNewDocument
713
+ // registered here will run before every subsequent navigation
714
+ // on this page target, so stealth patches survive open commands.
715
+ void (0, stealth_js_1.injectStealthViaCdp)(agent.id, workspaceDir);
711
716
  }
712
717
  // Build argv: --session <id> [--profile <path>] [--extension ...] [--executable-path ...] <command> <args...>
713
718
  // agent-browser only applies launch flags on the command that boots
@@ -727,6 +732,11 @@ async function runAgent(agent, message, onChunk, options) {
727
732
  maxBuffer: 10 * 1024 * 1024,
728
733
  });
729
734
  (0, session_manager_js_1.appendCommand)(browserState.handle, `${command} ${args.join(' ')}`.trim());
735
+ // Re-inject stealth after 'open' — agent-browser may create a new
736
+ // page target for the navigation, and new targets don't inherit
737
+ // Page.addScriptToEvaluateOnNewDocument from previous targets.
738
+ if (command === 'open')
739
+ void (0, stealth_js_1.injectStealthViaCdp)(agent.id, workspaceDir);
730
740
  const out = stdout.trim() || stderr.trim() || 'ok';
731
741
  return { content: [{ type: 'text', text: out }] };
732
742
  }
@@ -265,4 +265,20 @@
265
265
  configurable: true,
266
266
  });
267
267
  });
268
+
269
+ // ── 13. screen dimensions ───────────────────────────────────────────────
270
+ // Headless Chrome defaults to 800×600 which is trivially detected.
271
+ // Spoof a common 1920×1080 desktop resolution.
272
+ safe(() => {
273
+ const W = 1920, H = 1080;
274
+ const props = {
275
+ width: { get: () => W, configurable: true },
276
+ height: { get: () => H, configurable: true },
277
+ availWidth: { get: () => W, configurable: true },
278
+ availHeight: { get: () => H - 40, configurable: true }, // taskbar ~40px
279
+ colorDepth: { get: () => 24, configurable: true },
280
+ pixelDepth: { get: () => 24, configurable: true },
281
+ };
282
+ Object.defineProperties(Screen.prototype, props);
283
+ });
268
284
  })();
@@ -108,13 +108,22 @@ function detectChromePath() {
108
108
  // ── argv builder ──────────────────────────────────────────────────────────────
109
109
  /**
110
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.
111
+ * daemon.
112
+ *
113
+ * --executable-path Use real Chrome when available (less flagged than
114
+ * Playwright's bundled build).
115
+ * --args Chrome-level launch flags:
116
+ * --disable-blink-features=AutomationControlled
117
+ * Removes navigator.webdriver = true, which is the
118
+ * #1 bot-detection signal set by Playwright/CDP.
119
+ *
120
+ * The User-Agent is handled separately in prewarmStealthDaemon via a
121
+ * two-phase boot (discover real UA → restart with --user-agent <patched>).
113
122
  */
114
123
  function stealthArgv() {
115
124
  if (process.env.GRANCLAW_STEALTH_DISABLED === '1')
116
125
  return [];
117
- const argv = [];
126
+ const argv = ['--args', '--disable-blink-features=AutomationControlled'];
118
127
  const chrome = detectChromePath();
119
128
  if (chrome) {
120
129
  argv.push('--executable-path', chrome);
@@ -182,6 +191,9 @@ async function fetchPageTargets(browserCdpUrl) {
182
191
  * Page.addScriptToEvaluateOnNewDocument.
183
192
  * 2. Run it immediately on the current document via Runtime.evaluate so
184
193
  * patches are live without a reload.
194
+ *
195
+ * User-Agent patching is handled at the Chrome level via --user-agent in
196
+ * prewarmStealthDaemon, so it applies to every page target automatically.
185
197
  */
186
198
  function injectIntoPage(wsUrl, script) {
187
199
  return new Promise((resolve) => {
@@ -202,7 +214,6 @@ function injectIntoPage(wsUrl, script) {
202
214
  method: 'Runtime.evaluate',
203
215
  params: { expression: script, returnByValue: false },
204
216
  }));
205
- // Allow a short window for Chrome to ack, then close.
206
217
  setTimeout(() => { clearTimeout(timer); cleanup(ws); }, 400);
207
218
  });
208
219
  ws.on('error', () => { clearTimeout(timer); resolve(); });
@@ -238,37 +249,98 @@ async function injectStealthViaCdp(sessionId, workspaceDir) {
238
249
  }
239
250
  }
240
251
  // ── Daemon pre-warm ───────────────────────────────────────────────────────────
252
+ /**
253
+ * Fetch the raw User-Agent string from the browser's CDP /json/version endpoint.
254
+ * This reads the actual Chrome UA before any JS patches touch navigator.userAgent.
255
+ */
256
+ async function fetchRawBrowserUA(cdpUrl) {
257
+ return new Promise((resolve) => {
258
+ const match = /^wss?:\/\/([^/]+)\//.exec(cdpUrl);
259
+ if (!match) {
260
+ resolve('');
261
+ return;
262
+ }
263
+ const host = match[1];
264
+ const req = http_1.default.get(`http://${host}/json/version`, { timeout: 3000 }, (res) => {
265
+ let body = '';
266
+ res.on('data', (c) => { body += c.toString(); });
267
+ res.on('end', () => {
268
+ try {
269
+ const ua = JSON.parse(body)['User-Agent'] ?? '';
270
+ resolve(ua);
271
+ }
272
+ catch {
273
+ resolve('');
274
+ }
275
+ });
276
+ });
277
+ req.on('error', () => resolve(''));
278
+ req.on('timeout', () => { req.destroy(); resolve(''); });
279
+ });
280
+ }
241
281
  /**
242
282
  * Pre-warm the agent's browser daemon and register stealth before any
243
283
  * agent navigation. Call this once at agent process startup for agents
244
284
  * that have the browser tool enabled.
245
285
  *
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.
286
+ * Two-phase boot:
287
+ * Phase 1 Boot the daemon without a UA override to discover the real
288
+ * browser UA from /json/version (reading navigator.userAgent
289
+ * would give the JS-patched value, not the raw one).
290
+ * Phase 2 — Kill the daemon and restart it with:
291
+ * --user-agent <Chrome/X.Y.Z> strips "HeadlessChrome"
292
+ * --args --disable-blink-features=AutomationControlled
293
+ * removes navigator.webdriver = true for every page target
294
+ * Then inject Page.addScriptToEvaluateOnNewDocument so the
295
+ * JS stealth patches (WebGL, plugins, permissions…) apply to
296
+ * all subsequent navigations on the initial page target.
252
297
  *
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).
298
+ * Chrome-level flags apply globally to every page target the daemon creates,
299
+ * so they survive agent-browser open commands that spawn new targets.
256
300
  */
257
301
  async function prewarmStealthDaemon(sessionId, workspaceDir) {
258
302
  if (process.env.GRANCLAW_STEALTH_DISABLED === '1')
259
303
  return;
304
+ const bin = process.env.AGENT_BROWSER_BIN ?? 'agent-browser';
305
+ // ── Phase 1: boot daemon to discover the real browser UA ─────────────────
306
+ try {
307
+ await execFileAsync(bin, ['--session', sessionId, 'open', 'about:blank'], { cwd: workspaceDir, timeout: 15000 });
308
+ }
309
+ catch {
310
+ return; // No Chrome / wrong env — skip silently.
311
+ }
312
+ let patchedUA = '';
313
+ const cdpUrl = await discoverCdpUrl(sessionId, workspaceDir);
314
+ if (cdpUrl) {
315
+ const rawUA = await fetchRawBrowserUA(cdpUrl);
316
+ if (rawUA.includes('HeadlessChrome/')) {
317
+ patchedUA = rawUA.replace('HeadlessChrome/', 'Chrome/');
318
+ }
319
+ }
320
+ // Kill the daemon so we can restart it with corrected launch flags.
321
+ try {
322
+ await execFileAsync(bin, ['--session', sessionId, 'close', '--all'], { cwd: workspaceDir, timeout: 5000 });
323
+ }
324
+ catch { /* ignore — daemon may already be gone */ }
325
+ // ── Phase 2: restart with stealth Chrome flags ────────────────────────────
326
+ // stealthArgv() includes --args --disable-blink-features=AutomationControlled
327
+ // (fixes navigator.webdriver) and --executable-path when real Chrome is found.
328
+ // Persist the patched UA as a process-level env var so that every subsequent
329
+ // agent-browser call in this process (including startBrowserRecording's
330
+ // `record start`) inherits the correct UA even if the daemon restarts.
331
+ if (patchedUA)
332
+ process.env.AGENT_BROWSER_USER_AGENT = patchedUA;
333
+ const launchArgs = ['--session', sessionId, ...stealthArgv()];
334
+ if (patchedUA)
335
+ launchArgs.push('--user-agent', patchedUA);
336
+ launchArgs.push('open', 'about:blank');
260
337
  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 });
338
+ await execFileAsync(bin, launchArgs, { cwd: workspaceDir, timeout: 15000 });
266
339
  }
267
340
  catch {
268
- // Daemon failed to start (no Chrome, wrong env, etc.) — skip silently.
269
341
  return;
270
342
  }
271
- // Daemon is up. Register stealth for all future navigations.
343
+ // Daemon is up with correct flags. Register stealth JS for future navigations.
272
344
  await injectStealthViaCdp(sessionId, workspaceDir);
273
345
  }
274
346
  // ── Test helpers ──────────────────────────────────────────────────────────────
@@ -51,6 +51,12 @@ function startAllAgents() {
51
51
  const child = spawnAgent(agent, wsPort);
52
52
  registry.set(agent.id, { config: agent, wsPort, bbPort: null, pid: child.pid });
53
53
  console.log(`[orchestrator] agent "${agent.id}" started on ws port ${wsPort} (pid ${child.pid})`);
54
+ // Pre-warm the browser daemon for browser-capable agents so stealth is
55
+ // registered before the first navigation. Fire-and-forget.
56
+ if (agent.allowedTools?.includes('browser')) {
57
+ const workspaceDir = path_1.default.resolve(config_js_1.REPO_ROOT, agent.workspaceDir);
58
+ void (0, stealth_js_1.prewarmStealthDaemon)(agent.id, workspaceDir);
59
+ }
54
60
  });
55
61
  }
56
62
  function getManagedAgents() {
@@ -75,6 +81,10 @@ function restartAgent(agentId) {
75
81
  const child = spawnAgent(agent, managed.wsPort);
76
82
  registry.set(agentId, { config: agent, wsPort: managed.wsPort, bbPort: null, pid: child.pid });
77
83
  console.log(`[orchestrator] agent "${agentId}" restarted (pid ${child.pid})`);
84
+ if (agent.allowedTools?.includes('browser')) {
85
+ const workspaceDir = path_1.default.resolve(config_js_1.REPO_ROOT, agent.workspaceDir);
86
+ void (0, stealth_js_1.prewarmStealthDaemon)(agent.id, workspaceDir);
87
+ }
78
88
  }
79
89
  /**
80
90
  * Start a new agent. Assigns the next available port.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granclaw",
3
- "version": "0.0.1-beta.26",
3
+ "version": "0.0.1-beta.27",
4
4
  "description": "A personal AI assistant you run on your own machine.",
5
5
  "license": "MIT",
6
6
  "repository": {