granclaw 0.0.1-beta.26 → 0.0.1-beta.28

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,12 @@ 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
+ // Awaited so patches are live before the agent's first command fires.
716
+ await (0, stealth_js_1.injectStealthViaCdp)(agent.id, workspaceDir);
711
717
  }
712
718
  // Build argv: --session <id> [--profile <path>] [--extension ...] [--executable-path ...] <command> <args...>
713
719
  // agent-browser only applies launch flags on the command that boots
@@ -727,6 +733,12 @@ async function runAgent(agent, message, onChunk, options) {
727
733
  maxBuffer: 10 * 1024 * 1024,
728
734
  });
729
735
  (0, session_manager_js_1.appendCommand)(browserState.handle, `${command} ${args.join(' ')}`.trim());
736
+ // Re-inject stealth after 'open' — agent-browser may create a new
737
+ // page target for the navigation, and new targets don't inherit
738
+ // Page.addScriptToEvaluateOnNewDocument from previous targets.
739
+ // Awaited so patches are registered before the next tool call runs.
740
+ if (command === 'open')
741
+ await (0, stealth_js_1.injectStealthViaCdp)(agent.id, workspaceDir);
730
742
  const out = stdout.trim() || stderr.trim() || 'ok';
731
743
  return { content: [{ type: 'text', text: out }] };
732
744
  }
@@ -1002,6 +1014,18 @@ async function runAgent(agent, message, onChunk, options) {
1002
1014
  await (0, session_manager_js_1.finalizeSession)(browserState.handle, 'closed');
1003
1015
  }
1004
1016
  catch { /* best effort */ }
1017
+ // Navigate back to about:blank to release the current site's resources
1018
+ // (memory, service workers, open connections) while keeping the daemon
1019
+ // alive so Chrome-level stealth flags (--user-agent, --disable-blink-features)
1020
+ // survive into the next turn. We intentionally avoid 'tab close --all'
1021
+ // because that may kill the daemon entirely, losing the stealth flags.
1022
+ // Full cleanup happens in prewarmStealthDaemon's Phase 1 'close --all'
1023
+ // the next time the agent process starts.
1024
+ try {
1025
+ const bin = process.env.AGENT_BROWSER_BIN ?? 'agent-browser';
1026
+ await execFileAsync(bin, ['--session', agent.id, 'open', 'about:blank'], { cwd: workspaceDir, timeout: 5000 });
1027
+ }
1028
+ catch { /* best effort */ }
1005
1029
  }
1006
1030
  // Restore env var only if it was injected (envKey is set only after model guard)
1007
1031
  if (envKey !== undefined) {
@@ -265,4 +265,58 @@
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
+ });
284
+
285
+ // ── 14. Canvas fingerprint noise ────────────────────────────────────────
286
+ // Sites call toDataURL() or getImageData() on a hidden canvas and hash the
287
+ // result. The hash is deterministic per GPU/driver combination — headless
288
+ // SwiftShader produces a known fingerprint. Adding sub-pixel noise to each
289
+ // getImageData call makes the hash unique per session while remaining
290
+ // visually imperceptible.
291
+ safe(() => {
292
+ const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
293
+ CanvasRenderingContext2D.prototype.getImageData = function (x, y, w, h) {
294
+ const imageData = origGetImageData.call(this, x, y, w, h);
295
+ const data = imageData.data;
296
+ // XOR the last byte of every 4th pixel with a session-stable noise value
297
+ // so the fingerprint changes across sessions but is stable within one.
298
+ const noise = (Math.random() * 10 + 1) | 0; // 1–10, chosen once per page
299
+ for (let i = 3; i < data.length; i += 4 * 50) { // every 50th pixel's alpha
300
+ data[i] = Math.max(0, Math.min(255, data[i] ^ noise));
301
+ }
302
+ return imageData;
303
+ };
304
+ });
305
+
306
+ // ── 15. AudioContext fingerprint noise ──────────────────────────────────
307
+ // AudioContext.createOscillator + OfflineAudioContext rendering produces a
308
+ // deterministic output that fingerprinters hash. Patching getChannelData to
309
+ // add imperceptible noise breaks the hash without affecting audible output.
310
+ safe(() => {
311
+ const origGetChannelData = AudioBuffer.prototype.getChannelData;
312
+ AudioBuffer.prototype.getChannelData = function (channel) {
313
+ const channelData = origGetChannelData.call(this, channel);
314
+ if (channelData.length > 0) {
315
+ // Perturb a single sample by a sub-perceptible amount
316
+ const idx = channelData.length >> 1;
317
+ channelData[idx] += (Math.random() - 0.5) * 1e-7;
318
+ }
319
+ return channelData;
320
+ };
321
+ });
268
322
  })();
@@ -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,117 @@ 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
+ const profileDir = path_1.default.join(workspaceDir, '.browser-profile');
306
+ const uaCachePath = path_1.default.join(profileDir, 'ua.txt');
307
+ // ── UA cache: skip Phase 1 when the patched UA was already discovered ─────
308
+ // prewarmStealthDaemon writes the patched UA to <workspace>/.browser-profile/ua.txt
309
+ // after Phase 1 so subsequent backend restarts skip the extra boot cycle.
310
+ let patchedUA = '';
311
+ try {
312
+ const cached = fs_1.default.readFileSync(uaCachePath, 'utf-8').trim();
313
+ if (cached.startsWith('Mozilla/'))
314
+ patchedUA = cached;
315
+ }
316
+ catch { /* file absent — fall through to Phase 1 */ }
317
+ if (!patchedUA) {
318
+ // ── Phase 1: boot daemon to discover the real browser UA ─────────────────
319
+ try {
320
+ await execFileAsync(bin, ['--session', sessionId, 'open', 'about:blank'], { cwd: workspaceDir, timeout: 15000 });
321
+ }
322
+ catch {
323
+ return; // No Chrome / wrong env — skip silently.
324
+ }
325
+ const cdpUrl = await discoverCdpUrl(sessionId, workspaceDir);
326
+ if (cdpUrl) {
327
+ const rawUA = await fetchRawBrowserUA(cdpUrl);
328
+ if (rawUA.includes('HeadlessChrome/')) {
329
+ patchedUA = rawUA.replace('HeadlessChrome/', 'Chrome/');
330
+ // Persist so the next backend restart skips Phase 1.
331
+ try {
332
+ fs_1.default.mkdirSync(profileDir, { recursive: true });
333
+ fs_1.default.writeFileSync(uaCachePath, patchedUA, 'utf-8');
334
+ }
335
+ catch { /* best effort */ }
336
+ }
337
+ }
338
+ // Kill the Phase-1 daemon so we can restart it with corrected launch flags.
339
+ try {
340
+ await execFileAsync(bin, ['--session', sessionId, 'close', '--all'], { cwd: workspaceDir, timeout: 5000 });
341
+ }
342
+ catch { /* ignore — daemon may already be gone */ }
343
+ }
344
+ // ── Phase 2: restart with stealth Chrome flags ────────────────────────────
345
+ // stealthArgv() includes --args --disable-blink-features=AutomationControlled
346
+ // (fixes navigator.webdriver) and --executable-path when real Chrome is found.
347
+ // Persist the patched UA as a process-level env var so that every subsequent
348
+ // agent-browser call in this process (including startBrowserRecording's
349
+ // `record start`) inherits the correct UA even if the daemon restarts.
350
+ if (patchedUA)
351
+ process.env.AGENT_BROWSER_USER_AGENT = patchedUA;
352
+ const launchArgs = ['--session', sessionId, ...stealthArgv()];
353
+ if (patchedUA)
354
+ launchArgs.push('--user-agent', patchedUA);
355
+ launchArgs.push('open', 'about:blank');
260
356
  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 });
357
+ await execFileAsync(bin, launchArgs, { cwd: workspaceDir, timeout: 15000 });
266
358
  }
267
359
  catch {
268
- // Daemon failed to start (no Chrome, wrong env, etc.) — skip silently.
269
360
  return;
270
361
  }
271
- // Daemon is up. Register stealth for all future navigations.
362
+ // Daemon is up with correct flags. Register stealth JS for future navigations.
272
363
  await injectStealthViaCdp(sessionId, workspaceDir);
273
364
  }
274
365
  // ── 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.28",
4
4
  "description": "A personal AI assistant you run on your own machine.",
5
5
  "license": "MIT",
6
6
  "repository": {