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.
@@ -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 agentId = path_1.default.basename(workspaceDir);
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, agentId)
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 agentId = path_1.default.basename(workspaceDir);
136
+ const scheduleAgentId = agentId ?? path_1.default.basename(workspaceDir);
137
137
  try {
138
- const existing = (0, schedules_db_js_1.listSchedules)(agentId);
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)(agentId, {
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 ${agentId}`);
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
- const model = getModel(providerCfg.provider, modelId);
299
- if (!model) {
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
- const frontendPort = process.env.FRONTEND_PORT ?? process.env.PORT ?? '5173';
764
- const takeoverUrl = `http://${getLanIp()}:${frontendPort}/takeover/${token}`;
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
- * Central place where we build the agent-browser launch flags that hide
6
- * automation fingerprints. Two levers, both applied when available:
5
+ * Builds the agent-browser launch flags that hide 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
+ * stealthArgv() returns an argv fragment for the `agent-browser` boot command:
11
8
  *
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.
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
- * 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.
15
+ * --args --enable-extensions
16
+ * Required to activate extension loading in headless mode.
21
17
  *
22
- * Override hooks for users who want to pin a specific Chrome:
23
- * GRANCLAW_STEALTH_DISABLED=1 → no flags at all
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/ → ../../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.
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
- const STEALTH_EXTENSION_DIR = resolveExtensionDir();
58
- 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
- */
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
- * Return the argv fragment to pass to `agent-browser` on the command that
108
- * boots the daemon. agent-browser ignores launch flags on subsequent calls
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
- * The caller splices the result into its own argv e.g.:
112
- *
113
- * const argv = ['--session', agentId, ...stealthArgv(), 'open', url];
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
- if (STEALTH_EXTENSION_DIR) {
126
- argv.push('--extension', STEALTH_EXTENSION_DIR);
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
- * Test-only hook to reset the Chrome-path cache between tests.
136
- * @internal
137
- */
174
+ // ── Test helpers ──────────────────────────────────────────────────────────────
175
+ /** @internal */
138
176
  function __resetStealthCacheForTests() {
139
177
  cachedChromePath = undefined;
178
+ cachedChromeUA = undefined;
140
179
  }
@@ -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;
@@ -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 secrets = (0, secrets_vault_js_1.getSecrets)(agent.id);
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',