granclaw 0.0.1-beta.3 → 0.0.1-beta.31
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/dist/backend/agent/runner-pi.js +41 -13
- package/dist/backend/app-config.js +30 -0
- package/dist/backend/assets/stealth-extension/stealth.js +150 -0
- package/dist/backend/browser/stealth.js +86 -47
- package/dist/backend/data-db.js +0 -8
- package/dist/backend/index.js +5 -0
- package/dist/backend/orchestrator/agent-manager.js +2 -1
- package/dist/backend/orchestrator/browser-live.js +57 -0
- package/dist/backend/orchestrator/server.js +43 -6
- package/dist/backend/providers-config.js +75 -12
- package/dist/backend/secrets-vault.js +15 -15
- package/dist/backend/telemetry.js +35 -0
- package/dist/backend/workspace-pool.js +8 -0
- package/dist/frontend/assets/index-BkzOwn-A.css +1 -0
- package/dist/frontend/assets/index-DvyFv8xm.js +144 -0
- package/dist/frontend/index.html +2 -2
- package/dist/home.js +20 -0
- package/dist/index.js +10 -17
- package/dist/telemetry.js +37 -0
- package/package.json +2 -1
- package/templates/AGENT.onboarding.md +3 -3
- package/templates/SYSTEM.md +12 -3
- package/dist/frontend/assets/index-CZcU3XNC.js +0 -143
- package/dist/frontend/assets/index-CkgRytfR.css +0 -1
|
@@ -62,7 +62,7 @@ function resolveTemplatesDir() {
|
|
|
62
62
|
// ── bootstrapWorkspace ───────────────────────────────────────────────────────
|
|
63
63
|
// Pi-specific workspace bootstrap: uses AGENT.md and .agent/skills/ instead of
|
|
64
64
|
// the Claude-specific CLAUDE.md and .claude/skills/ paths.
|
|
65
|
-
function bootstrapWorkspace(workspaceDir) {
|
|
65
|
+
function bootstrapWorkspace(workspaceDir, agentId) {
|
|
66
66
|
fs_1.default.mkdirSync(workspaceDir, { recursive: true });
|
|
67
67
|
// AGENT.md — prefer AGENT.md, fall back to CLAUDE.md for existing workspaces
|
|
68
68
|
const agentMd = path_1.default.join(workspaceDir, 'AGENT.md');
|
|
@@ -71,13 +71,13 @@ function bootstrapWorkspace(workspaceDir) {
|
|
|
71
71
|
const template = path_1.default.join(resolveTemplatesDir(), 'AGENT.onboarding.md');
|
|
72
72
|
if (fs_1.default.existsSync(template)) {
|
|
73
73
|
// Stamp agent ID and system timezone so the agent never needs to ask
|
|
74
|
-
const
|
|
74
|
+
const workspaceName = path_1.default.basename(workspaceDir);
|
|
75
75
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
76
76
|
const content = fs_1.default.readFileSync(template, 'utf8')
|
|
77
|
-
.replace(/YOUR_AGENT_ID/g,
|
|
77
|
+
.replace(/YOUR_AGENT_ID/g, workspaceName)
|
|
78
78
|
.replace(/GRANCLAW_TIMEZONE/g, timezone);
|
|
79
79
|
fs_1.default.writeFileSync(agentMd, content);
|
|
80
|
-
console.log(`[runner-pi] wrote AGENT.md to ${workspaceDir} (agentId=${agentId})`);
|
|
80
|
+
console.log(`[runner-pi] wrote AGENT.md to ${workspaceDir} (agentId=${agentId ?? workspaceName})`);
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
// .mcp.json — prevent inheriting host MCP servers
|
|
@@ -133,13 +133,13 @@ function bootstrapWorkspace(workspaceDir) {
|
|
|
133
133
|
// Sessions directory for pi JSONL session files
|
|
134
134
|
fs_1.default.mkdirSync(path_1.default.join(workspaceDir, '.pi-sessions'), { recursive: true });
|
|
135
135
|
// Default vault housekeeping schedule
|
|
136
|
-
const
|
|
136
|
+
const scheduleAgentId = agentId ?? path_1.default.basename(workspaceDir);
|
|
137
137
|
try {
|
|
138
|
-
const existing = (0, schedules_db_js_1.listSchedules)(
|
|
138
|
+
const existing = (0, schedules_db_js_1.listSchedules)(scheduleAgentId);
|
|
139
139
|
if (!existing.some(s => s.name === 'Vault housekeeping')) {
|
|
140
140
|
const cron = '30 23 * * *';
|
|
141
141
|
const nextRun = (0, cron_parser_1.parseExpression)(cron, { tz: 'Asia/Singapore' }).next().getTime();
|
|
142
|
-
(0, schedules_db_js_1.createSchedule)(
|
|
142
|
+
(0, schedules_db_js_1.createSchedule)(scheduleAgentId, {
|
|
143
143
|
name: 'Vault housekeeping',
|
|
144
144
|
message: [
|
|
145
145
|
'Daily vault housekeeping. Use your built-in tools only — no scripts, no bash commands.',
|
|
@@ -186,7 +186,7 @@ function bootstrapWorkspace(workspaceDir) {
|
|
|
186
186
|
timezone: 'Asia/Singapore',
|
|
187
187
|
nextRun,
|
|
188
188
|
});
|
|
189
|
-
console.log(`[runner-pi] created default vault housekeeping schedule for ${
|
|
189
|
+
console.log(`[runner-pi] created default vault housekeeping schedule for ${scheduleAgentId}`);
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
catch { /* schedules DB may not be ready yet */ }
|
|
@@ -219,9 +219,17 @@ function providerEnvKey(provider) {
|
|
|
219
219
|
mistral: 'MISTRAL_API_KEY',
|
|
220
220
|
cerebras: 'CEREBRAS_API_KEY',
|
|
221
221
|
openrouter: 'OPENROUTER_API_KEY',
|
|
222
|
+
// freetier routes through the enterprise proxy (OpenAI-compatible) using the same env var
|
|
223
|
+
freetier: 'OPENROUTER_API_KEY',
|
|
222
224
|
};
|
|
223
225
|
return keys[provider] ?? `${provider.toUpperCase()}_API_KEY`;
|
|
224
226
|
}
|
|
227
|
+
// "freetier" is an enterprise-managed provider that proxies through our internal LLM gateway.
|
|
228
|
+
// Pi-ai doesn't know "freetier", so we resolve it to "openrouter" for model lookup,
|
|
229
|
+
// then override the baseUrl to point at the proxy.
|
|
230
|
+
function resolvePiProvider(provider) {
|
|
231
|
+
return provider === 'freetier' ? 'openrouter' : provider;
|
|
232
|
+
}
|
|
225
233
|
// ── Agent name extraction ────────────────────────────────────────────────────
|
|
226
234
|
function extractAgentName(workspaceDir) {
|
|
227
235
|
// Check AGENT.md first (pi runner), fall back to CLAUDE.md (legacy)
|
|
@@ -250,7 +258,7 @@ function getLanIp() {
|
|
|
250
258
|
async function runAgent(agent, message, onChunk, options) {
|
|
251
259
|
const workspaceDir = path_1.default.resolve(config_js_1.REPO_ROOT, agent.workspaceDir);
|
|
252
260
|
const channelId = options?.channelId ?? 'ui';
|
|
253
|
-
bootstrapWorkspace(workspaceDir);
|
|
261
|
+
bootstrapWorkspace(workspaceDir, agent.id);
|
|
254
262
|
// Browser session state — captured by closure in the `browser` tool and
|
|
255
263
|
// finalized in the finally block. Null means the agent never touched the
|
|
256
264
|
// browser this turn, so there's nothing to clean up.
|
|
@@ -295,14 +303,21 @@ async function runAgent(agent, message, onChunk, options) {
|
|
|
295
303
|
// getModel() expects KnownProvider literals at the type level, but our
|
|
296
304
|
// provider string comes from runtime config. The cast is safe — getModel()
|
|
297
305
|
// returns undefined for unknown provider/model combos, which we handle below.
|
|
298
|
-
|
|
299
|
-
|
|
306
|
+
// resolvePiProvider maps display-only providers (e.g. "freetier") to their
|
|
307
|
+
// underlying pi-ai provider ("openrouter") for model lookup.
|
|
308
|
+
const piProvider = resolvePiProvider(providerCfg.provider);
|
|
309
|
+
const rawModel = getModel(piProvider, modelId);
|
|
310
|
+
if (!rawModel) {
|
|
300
311
|
onChunk({
|
|
301
312
|
type: 'error',
|
|
302
313
|
message: `Model "${modelId}" not found for provider "${providerCfg.provider}". Check Settings.`,
|
|
303
314
|
});
|
|
304
315
|
return;
|
|
305
316
|
}
|
|
317
|
+
// For managed providers (e.g. "freetier"), override the baseUrl so requests
|
|
318
|
+
// are routed through the enterprise LLM proxy instead of the upstream directly.
|
|
319
|
+
const baseUrlOverride = (0, providers_config_js_1.getProviderBaseUrl)(providerCfg.provider);
|
|
320
|
+
const model = baseUrlOverride ? { ...rawModel, baseUrl: baseUrlOverride } : rawModel;
|
|
306
321
|
// Inject the API key into the env so pi-ai's credential chain picks it up.
|
|
307
322
|
// Done here (after model guard) so the finally block always restores it.
|
|
308
323
|
envKey = providerEnvKey(providerCfg.provider);
|
|
@@ -760,8 +775,14 @@ async function runAgent(agent, message, onChunk, options) {
|
|
|
760
775
|
};
|
|
761
776
|
}
|
|
762
777
|
const token = (0, crypto_1.randomUUID)();
|
|
763
|
-
|
|
764
|
-
|
|
778
|
+
// GRANCLAW_PUBLIC_URL overrides LAN-IP detection for cloud/enterprise deployments
|
|
779
|
+
// (e.g. https://myslug.host.granclaw.com). Falls back to LAN IP for local use.
|
|
780
|
+
const takeoverUrl = process.env.GRANCLAW_PUBLIC_URL
|
|
781
|
+
? `${process.env.GRANCLAW_PUBLIC_URL.replace(/\/$/, '')}/takeover/${token}`
|
|
782
|
+
: (() => {
|
|
783
|
+
const frontendPort = process.env.FRONTEND_PORT ?? process.env.PORT ?? '5173';
|
|
784
|
+
return `http://${getLanIp()}:${frontendPort}/takeover/${token}`;
|
|
785
|
+
})();
|
|
765
786
|
(0, takeover_state_js_1.setTakeover)(agent.id, {
|
|
766
787
|
agentId: agent.id,
|
|
767
788
|
channelId,
|
|
@@ -981,6 +1002,13 @@ async function runAgent(agent, message, onChunk, options) {
|
|
|
981
1002
|
await (0, session_manager_js_1.finalizeSession)(browserState.handle, 'closed');
|
|
982
1003
|
}
|
|
983
1004
|
catch { /* best effort */ }
|
|
1005
|
+
// Navigate back to about:blank to release the current site's resources
|
|
1006
|
+
// (memory, service workers, open connections) while keeping the daemon alive.
|
|
1007
|
+
try {
|
|
1008
|
+
const bin = process.env.AGENT_BROWSER_BIN ?? 'agent-browser';
|
|
1009
|
+
await execFileAsync(bin, ['--session', agent.id, 'open', 'about:blank'], { cwd: workspaceDir, timeout: 5000 });
|
|
1010
|
+
}
|
|
1011
|
+
catch { /* best effort */ }
|
|
984
1012
|
}
|
|
985
1013
|
// Restore env var only if it was injected (envKey is set only after model guard)
|
|
986
1014
|
if (envKey !== undefined) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// packages/backend/src/app-config.ts
|
|
3
|
+
//
|
|
4
|
+
// Enterprise UI overrides. Reads config-app.json from GRANCLAW_HOME.
|
|
5
|
+
// If the file is absent (standard install), all flags default to true (show everything).
|
|
6
|
+
// Enterprise control seeds this file at provision time to restrict certain UI elements.
|
|
7
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
8
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
9
|
+
};
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.getAppConfig = getAppConfig;
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const config_js_1 = require("./config.js");
|
|
15
|
+
const CONFIG_APP_PATH = path_1.default.join(config_js_1.GRANCLAW_HOME, 'config-app.json');
|
|
16
|
+
const DEFAULTS = {
|
|
17
|
+
showWorkspaceDirConfig: true,
|
|
18
|
+
showBraveSearchConfig: true,
|
|
19
|
+
};
|
|
20
|
+
function getAppConfig() {
|
|
21
|
+
try {
|
|
22
|
+
const envPath = process.env.APP_CONFIG_PATH?.trim();
|
|
23
|
+
const p = envPath ? path_1.default.resolve(envPath) : CONFIG_APP_PATH;
|
|
24
|
+
const raw = JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
|
|
25
|
+
return { ...DEFAULTS, ...raw };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return { ...DEFAULTS };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -217,4 +217,154 @@
|
|
|
217
217
|
return result.replace(/at .*puppeteer_evaluation_script.*\n?/g, '');
|
|
218
218
|
};
|
|
219
219
|
});
|
|
220
|
+
|
|
221
|
+
// ── 10. navigator.userAgent — strip HeadlessChrome ─────────────────────
|
|
222
|
+
// Headless Chrome embeds "HeadlessChrome/" in the UA string instead of
|
|
223
|
+
// "Chrome/". Primary fix is Emulation.setUserAgentOverride via CDP (applied
|
|
224
|
+
// in stealth.ts before this script runs). This JS patch is a belt-and-braces
|
|
225
|
+
// fallback covering any reads that bypass the CDP override.
|
|
226
|
+
// Two-level attempt: prototype first (cleanest), then instance-level if the
|
|
227
|
+
// prototype property is non-configurable (Chrome 120+ tightened this).
|
|
228
|
+
safe(() => {
|
|
229
|
+
const realUA = navigator.userAgent.replace('HeadlessChrome/', 'Chrome/');
|
|
230
|
+
if (realUA === navigator.userAgent) return; // CDP override already applied — no-op
|
|
231
|
+
try {
|
|
232
|
+
Object.defineProperty(Navigator.prototype, 'userAgent', {
|
|
233
|
+
get: () => realUA,
|
|
234
|
+
configurable: true,
|
|
235
|
+
});
|
|
236
|
+
} catch (_) {
|
|
237
|
+
// Prototype descriptor is non-configurable — fall back to instance property
|
|
238
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
239
|
+
get: () => realUA,
|
|
240
|
+
configurable: true,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ── 11. navigator.userAgentData — Client Hints API ─────────────────────
|
|
246
|
+
// The modern Client Hints UA API also leaks "Headless" in its brand list.
|
|
247
|
+
// Patch brands to strip the "Headless" prefix so sites using getHighEntropyValues
|
|
248
|
+
// or brands directly see a normal Chrome brand string.
|
|
249
|
+
safe(() => {
|
|
250
|
+
if (typeof navigator.userAgentData === 'undefined') return;
|
|
251
|
+
const uad = navigator.userAgentData;
|
|
252
|
+
const brands = (uad.brands || []).map((b) => ({
|
|
253
|
+
brand: b.brand.replace(/^Headless/i, ''),
|
|
254
|
+
version: b.version,
|
|
255
|
+
}));
|
|
256
|
+
const patchedUad = new Proxy(uad, {
|
|
257
|
+
get(target, prop) {
|
|
258
|
+
if (prop === 'brands') return brands;
|
|
259
|
+
if (prop === 'mobile') return false;
|
|
260
|
+
const val = Reflect.get(target, prop);
|
|
261
|
+
return typeof val === 'function' ? val.bind(target) : val;
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
Object.defineProperty(Navigator.prototype, 'userAgentData', {
|
|
266
|
+
get: () => patchedUad,
|
|
267
|
+
configurable: true,
|
|
268
|
+
});
|
|
269
|
+
} catch (_) {
|
|
270
|
+
Object.defineProperty(navigator, 'userAgentData', {
|
|
271
|
+
get: () => patchedUad,
|
|
272
|
+
configurable: true,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ── 12. navigator.deviceMemory ──────────────────────────────────────────
|
|
278
|
+
// Headless Chrome may expose the host's actual RAM or a low default.
|
|
279
|
+
// Spoofing 8 GB matches the most common desktop tier and avoids leaking
|
|
280
|
+
// the container/VM memory footprint to fingerprinting scripts.
|
|
281
|
+
safe(() => {
|
|
282
|
+
try {
|
|
283
|
+
Object.defineProperty(Navigator.prototype, 'deviceMemory', {
|
|
284
|
+
get: () => 8,
|
|
285
|
+
configurable: true,
|
|
286
|
+
});
|
|
287
|
+
} catch (_) {
|
|
288
|
+
Object.defineProperty(navigator, 'deviceMemory', {
|
|
289
|
+
get: () => 8,
|
|
290
|
+
configurable: true,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ── 12b. performance.memory (CHR_MEMORY) ───────────────────────────────
|
|
296
|
+
// Sannysoft's CHR_MEMORY test reads performance.memory — the non-standard
|
|
297
|
+
// V8 heap API. It is disabled by default in headless Chrome (returns undefined).
|
|
298
|
+
// We enable it at the Chrome level via --enable-precise-memory-info, but also
|
|
299
|
+
// define it here as a belt-and-braces fallback. Do NOT guard on !performance.memory
|
|
300
|
+
// — the whole point is to make it exist when it otherwise wouldn't.
|
|
301
|
+
safe(() => {
|
|
302
|
+
if (typeof performance === 'undefined') return;
|
|
303
|
+
const fakeMem = {
|
|
304
|
+
jsHeapSizeLimit: 2172649472, // ~2 GB — typical 64-bit Chrome desktop
|
|
305
|
+
totalJSHeapSize: 67108864, // 64 MB used
|
|
306
|
+
usedJSHeapSize: 23068672, // 22 MB live
|
|
307
|
+
};
|
|
308
|
+
try {
|
|
309
|
+
Object.defineProperty(performance, 'memory', {
|
|
310
|
+
get: () => fakeMem,
|
|
311
|
+
configurable: true,
|
|
312
|
+
enumerable: true,
|
|
313
|
+
});
|
|
314
|
+
} catch (_) { /* already defined and non-configurable — --enable-precise-memory-info handles it */ }
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ── 13. screen dimensions ───────────────────────────────────────────────
|
|
318
|
+
// Headless Chrome defaults to 800×600 which is trivially detected.
|
|
319
|
+
// Spoof a common 1920×1080 desktop resolution.
|
|
320
|
+
safe(() => {
|
|
321
|
+
const W = 1920, H = 1080;
|
|
322
|
+
const props = {
|
|
323
|
+
width: { get: () => W, configurable: true },
|
|
324
|
+
height: { get: () => H, configurable: true },
|
|
325
|
+
availWidth: { get: () => W, configurable: true },
|
|
326
|
+
availHeight: { get: () => H - 40, configurable: true }, // taskbar ~40px
|
|
327
|
+
colorDepth: { get: () => 24, configurable: true },
|
|
328
|
+
pixelDepth: { get: () => 24, configurable: true },
|
|
329
|
+
};
|
|
330
|
+
Object.defineProperties(Screen.prototype, props);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ── 14. Canvas fingerprint noise ────────────────────────────────────────
|
|
334
|
+
// Sites call toDataURL() or getImageData() on a hidden canvas and hash the
|
|
335
|
+
// result. The hash is deterministic per GPU/driver combination — headless
|
|
336
|
+
// SwiftShader produces a known fingerprint. Adding sub-pixel noise to each
|
|
337
|
+
// getImageData call makes the hash unique per session while remaining
|
|
338
|
+
// visually imperceptible.
|
|
339
|
+
safe(() => {
|
|
340
|
+
const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
|
|
341
|
+
CanvasRenderingContext2D.prototype.getImageData = function (x, y, w, h) {
|
|
342
|
+
const imageData = origGetImageData.call(this, x, y, w, h);
|
|
343
|
+
const data = imageData.data;
|
|
344
|
+
// XOR the last byte of every 4th pixel with a session-stable noise value
|
|
345
|
+
// so the fingerprint changes across sessions but is stable within one.
|
|
346
|
+
const noise = (Math.random() * 10 + 1) | 0; // 1–10, chosen once per page
|
|
347
|
+
for (let i = 3; i < data.length; i += 4 * 50) { // every 50th pixel's alpha
|
|
348
|
+
data[i] = Math.max(0, Math.min(255, data[i] ^ noise));
|
|
349
|
+
}
|
|
350
|
+
return imageData;
|
|
351
|
+
};
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ── 15. AudioContext fingerprint noise ──────────────────────────────────
|
|
355
|
+
// AudioContext.createOscillator + OfflineAudioContext rendering produces a
|
|
356
|
+
// deterministic output that fingerprinters hash. Patching getChannelData to
|
|
357
|
+
// add imperceptible noise breaks the hash without affecting audible output.
|
|
358
|
+
safe(() => {
|
|
359
|
+
const origGetChannelData = AudioBuffer.prototype.getChannelData;
|
|
360
|
+
AudioBuffer.prototype.getChannelData = function (channel) {
|
|
361
|
+
const channelData = origGetChannelData.call(this, channel);
|
|
362
|
+
if (channelData.length > 0) {
|
|
363
|
+
// Perturb a single sample by a sub-perceptible amount
|
|
364
|
+
const idx = channelData.length >> 1;
|
|
365
|
+
channelData[idx] += (Math.random() - 0.5) * 1e-7;
|
|
366
|
+
}
|
|
367
|
+
return channelData;
|
|
368
|
+
};
|
|
369
|
+
});
|
|
220
370
|
})();
|
|
@@ -2,25 +2,28 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* stealth.ts
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* automation fingerprints. Two levers, both applied when available:
|
|
5
|
+
* Builds the agent-browser launch flags that hide automation fingerprints.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
* which patches navigator.webdriver and friends
|
|
10
|
-
* at document_start, before any page script runs.
|
|
7
|
+
* stealthArgv() returns an argv fragment for the `agent-browser` boot command:
|
|
11
8
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
9
|
+
* --args --load-extension=<dir>
|
|
10
|
+
* Loads the GranClaw stealth MV3 extension. The extension content script
|
|
11
|
+
* runs at document_start in world=MAIN — before any page JS — patching
|
|
12
|
+
* navigator.webdriver, UA, plugins, WebGL, canvas, audio, and more.
|
|
13
|
+
* Works in Chrome 112+ new headless mode without a display (no Xvfb needed).
|
|
17
14
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* fragment they can spread into their existing spawn line.
|
|
15
|
+
* --args --enable-extensions
|
|
16
|
+
* Required to activate extension loading in headless mode.
|
|
21
17
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* --args --disable-blink-features=AutomationControlled
|
|
19
|
+
* Removes navigator.webdriver = true at the Chrome level, the #1 signal.
|
|
20
|
+
*
|
|
21
|
+
* --executable-path <path>
|
|
22
|
+
* Uses real installed Chrome/Chromium instead of Playwright's bundled
|
|
23
|
+
* build (less fingerprinted).
|
|
24
|
+
*
|
|
25
|
+
* Override hooks:
|
|
26
|
+
* GRANCLAW_STEALTH_DISABLED=1 → no-op
|
|
24
27
|
* GRANCLAW_CHROME_PATH=/abs/path/chrome → force a specific binary
|
|
25
28
|
*/
|
|
26
29
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
@@ -32,16 +35,13 @@ exports.stealthArgv = stealthArgv;
|
|
|
32
35
|
exports.__resetStealthCacheForTests = __resetStealthCacheForTests;
|
|
33
36
|
const fs_1 = __importDefault(require("fs"));
|
|
34
37
|
const path_1 = __importDefault(require("path"));
|
|
38
|
+
const child_process_1 = require("child_process");
|
|
39
|
+
// ── Extension dir ─────────────────────────────────────────────────────────────
|
|
35
40
|
/**
|
|
36
41
|
* Resolve the stealth-extension directory across three layouts:
|
|
37
|
-
*
|
|
38
42
|
* - dev (tsx src): packages/backend/src/browser/ → ../../assets/stealth-extension
|
|
39
43
|
* - backend standalone: packages/backend/dist/browser/ → ../../assets/stealth-extension
|
|
40
|
-
* - cli bundled publish: packages/cli/dist/backend/browser/ →
|
|
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.
|
|
44
|
+
* - cli bundled publish: packages/cli/dist/backend/browser/ → ../assets/stealth-extension
|
|
45
45
|
*/
|
|
46
46
|
function resolveExtensionDir() {
|
|
47
47
|
const candidates = [
|
|
@@ -54,17 +54,13 @@ function resolveExtensionDir() {
|
|
|
54
54
|
}
|
|
55
55
|
return null;
|
|
56
56
|
}
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
*/
|
|
57
|
+
exports.STEALTH_EXTENSION_DIR = resolveExtensionDir();
|
|
58
|
+
// ── Chrome binary detection + UA derivation ───────────────────────────────────
|
|
63
59
|
let cachedChromePath;
|
|
64
60
|
function detectChromePath() {
|
|
65
61
|
if (cachedChromePath !== undefined)
|
|
66
62
|
return cachedChromePath;
|
|
67
|
-
const override = process.env.GRANCLAW_CHROME_PATH;
|
|
63
|
+
const override = process.env.GRANCLAW_CHROME_PATH || process.env.AGENT_BROWSER_CHROME_PATH;
|
|
68
64
|
if (override && fs_1.default.existsSync(override)) {
|
|
69
65
|
cachedChromePath = override;
|
|
70
66
|
return override;
|
|
@@ -104,26 +100,70 @@ function detectChromePath() {
|
|
|
104
100
|
return null;
|
|
105
101
|
}
|
|
106
102
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* to an already-running daemon, so these must land on the first command.
|
|
103
|
+
* Detect the installed Chrome/Chromium version and return a non-headless UA string.
|
|
104
|
+
* e.g. "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
|
|
110
105
|
*
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
106
|
+
* Returns null if Chrome can't be found or version can't be parsed.
|
|
107
|
+
*/
|
|
108
|
+
let cachedChromeUA;
|
|
109
|
+
function detectChromeUA() {
|
|
110
|
+
if (cachedChromeUA !== undefined)
|
|
111
|
+
return cachedChromeUA;
|
|
112
|
+
const override = process.env.GRANCLAW_STEALTH_UA;
|
|
113
|
+
if (override) {
|
|
114
|
+
cachedChromeUA = override;
|
|
115
|
+
return override;
|
|
116
|
+
}
|
|
117
|
+
const chromePath = detectChromePath();
|
|
118
|
+
if (!chromePath) {
|
|
119
|
+
cachedChromeUA = null;
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const output = (0, child_process_1.execFileSync)(chromePath, ['--version'], { encoding: 'utf8', timeout: 5000 });
|
|
124
|
+
// "Chromium 147.0.7280.66 built on Debian..." or "Google Chrome 120.0.6099.109"
|
|
125
|
+
const match = output.match(/(\d+)\.\d+\.\d+\.\d+/);
|
|
126
|
+
if (!match) {
|
|
127
|
+
cachedChromeUA = null;
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const major = match[1];
|
|
131
|
+
const platform = process.platform === 'darwin'
|
|
132
|
+
? 'Macintosh; Intel Mac OS X 10_15_7'
|
|
133
|
+
: 'X11; Linux x86_64';
|
|
134
|
+
cachedChromeUA = `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
|
|
135
|
+
return cachedChromeUA;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
cachedChromeUA = null;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// ── argv builder ──────────────────────────────────────────────────────────────
|
|
143
|
+
/**
|
|
144
|
+
* Return the argv fragment for the `agent-browser` boot command.
|
|
145
|
+
* Spread into the argv before the subcommand, e.g.:
|
|
146
|
+
* agent-browser --session <id> ...stealthArgv() open about:blank
|
|
114
147
|
*/
|
|
115
148
|
function stealthArgv() {
|
|
116
|
-
// DISABLED 2026-04-11: the stealth extension / real-Chrome swap was
|
|
117
|
-
// producing buggy behaviour in agent browser sessions. Keep the code
|
|
118
|
-
// in place so we can re-enable with one edit once we understand why.
|
|
119
|
-
// Set GRANCLAW_STEALTH_ENABLED=1 to opt back in.
|
|
120
|
-
if (process.env.GRANCLAW_STEALTH_ENABLED !== '1')
|
|
121
|
-
return [];
|
|
122
149
|
if (process.env.GRANCLAW_STEALTH_DISABLED === '1')
|
|
123
150
|
return [];
|
|
124
|
-
const argv = [
|
|
125
|
-
|
|
126
|
-
|
|
151
|
+
const argv = [
|
|
152
|
+
'--args', '--disable-blink-features=AutomationControlled',
|
|
153
|
+
'--args', '--enable-extensions',
|
|
154
|
+
// Enable the V8 heap memory API (performance.memory) — disabled by default in headless.
|
|
155
|
+
'--args', '--enable-precise-memory-info',
|
|
156
|
+
];
|
|
157
|
+
// Override the UA at the Chrome level. navigator.userAgent in Chrome 120+ has
|
|
158
|
+
// a non-configurable prototype property that JS Object.defineProperty cannot
|
|
159
|
+
// patch — the only reliable fix is to set it before the browser starts.
|
|
160
|
+
const ua = detectChromeUA();
|
|
161
|
+
if (ua) {
|
|
162
|
+
argv.push('--args', `--user-agent=${ua}`);
|
|
163
|
+
}
|
|
164
|
+
if (exports.STEALTH_EXTENSION_DIR) {
|
|
165
|
+
argv.push('--args', `--load-extension=${exports.STEALTH_EXTENSION_DIR}`);
|
|
166
|
+
argv.push('--args', `--disable-extensions-except=${exports.STEALTH_EXTENSION_DIR}`);
|
|
127
167
|
}
|
|
128
168
|
const chrome = detectChromePath();
|
|
129
169
|
if (chrome) {
|
|
@@ -131,10 +171,9 @@ function stealthArgv() {
|
|
|
131
171
|
}
|
|
132
172
|
return argv;
|
|
133
173
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
* @internal
|
|
137
|
-
*/
|
|
174
|
+
// ── Test helpers ──────────────────────────────────────────────────────────────
|
|
175
|
+
/** @internal */
|
|
138
176
|
function __resetStealthCacheForTests() {
|
|
139
177
|
cachedChromePath = undefined;
|
|
178
|
+
cachedChromeUA = undefined;
|
|
140
179
|
}
|
package/dist/backend/data-db.js
CHANGED
|
@@ -85,14 +85,6 @@ function getDataDb() {
|
|
|
85
85
|
CREATE INDEX IF NOT EXISTS idx_takeovers_agent
|
|
86
86
|
ON takeovers (agent_id);
|
|
87
87
|
|
|
88
|
-
-- ── secrets table ──────────────────────────────────────────────────────
|
|
89
|
-
CREATE TABLE IF NOT EXISTS secrets (
|
|
90
|
-
agent_id TEXT NOT NULL,
|
|
91
|
-
name TEXT NOT NULL,
|
|
92
|
-
value TEXT NOT NULL,
|
|
93
|
-
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
94
|
-
PRIMARY KEY (agent_id, name)
|
|
95
|
-
);
|
|
96
88
|
`);
|
|
97
89
|
_db = db;
|
|
98
90
|
return db;
|
package/dist/backend/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const net_1 = __importDefault(require("net"));
|
|
|
8
8
|
const server_js_1 = require("./orchestrator/server.js");
|
|
9
9
|
const agent_manager_js_1 = require("./orchestrator/agent-manager.js");
|
|
10
10
|
const scheduler_js_1 = require("./scheduler.js");
|
|
11
|
+
const telemetry_js_1 = require("./telemetry.js");
|
|
11
12
|
const PORT = Number(process.env.PORT ?? 3001);
|
|
12
13
|
function checkPortAvailable(port, label) {
|
|
13
14
|
return new Promise((resolve, reject) => {
|
|
@@ -40,6 +41,8 @@ async function preflight() {
|
|
|
40
41
|
}
|
|
41
42
|
preflight()
|
|
42
43
|
.then(() => {
|
|
44
|
+
(0, telemetry_js_1.initTelemetry)();
|
|
45
|
+
(0, telemetry_js_1.capture)('server_started', { port: PORT, nodeVersion: process.version });
|
|
43
46
|
(0, agent_manager_js_1.startAllAgents)();
|
|
44
47
|
(0, scheduler_js_1.startScheduler)();
|
|
45
48
|
const server = (0, server_js_1.createServer)();
|
|
@@ -51,3 +54,5 @@ preflight()
|
|
|
51
54
|
console.error(err.message);
|
|
52
55
|
process.exit(1);
|
|
53
56
|
});
|
|
57
|
+
process.on('SIGTERM', () => { void (0, telemetry_js_1.shutdownTelemetry)(); });
|
|
58
|
+
process.on('SIGINT', () => { void (0, telemetry_js_1.shutdownTelemetry)(); });
|
|
@@ -110,7 +110,8 @@ function spawnAgent(agent, wsPort) {
|
|
|
110
110
|
const isTs = __filename.endsWith('.ts');
|
|
111
111
|
const ext = isTs ? '.ts' : '.js';
|
|
112
112
|
const agentScript = path_1.default.resolve(__dirname, `../agent/process${ext}`);
|
|
113
|
-
const
|
|
113
|
+
const workspaceDir = path_1.default.resolve(config_js_1.REPO_ROOT, agent.workspaceDir);
|
|
114
|
+
const secrets = (0, secrets_vault_js_1.getSecrets)(workspaceDir);
|
|
114
115
|
const secretKeys = Object.keys(secrets);
|
|
115
116
|
if (secretKeys.length > 0) {
|
|
116
117
|
console.log(`[orchestrator] injecting ${secretKeys.length} secrets into agent "${agent.id}": ${secretKeys.join(', ')}`);
|
|
@@ -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',
|