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.
|
|
112
|
-
*
|
|
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
|
-
*
|
|
247
|
-
* 1
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
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
|
-
*
|
|
254
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|