crawlforge-mcp-server 6.5.0 → 6.6.0

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.
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Snapshot — the accessibility-style page tree that hands an agent stable
3
+ * element refs (`@e1`, `@e2`, …) to act on instead of guessed CSS selectors.
4
+ *
5
+ * Why ours and not Playwright's: 1.62's `locator.ariaSnapshot()` emits YAML
6
+ * with no element refs at all, and `page._snapshotForAI()` is private API we
7
+ * will not depend on. The walk below is injected by us and returns the tree
8
+ * and the refs from one pass.
9
+ *
10
+ * A REF LIVES IN TWO PLACES, and the halves do different jobs:
11
+ *
12
+ * 1. In the page — the walk stamps `data-cf-ref="e1"` onto every element it
13
+ * refs, so a ref resolves to an ordinary CSS selector,
14
+ * `[data-cf-ref="e1"]`. That is what makes refs work with every existing
15
+ * action path for free, including the stealth human-behaviour code that
16
+ * takes a raw selector string.
17
+ * 2. In Node — a WeakMap<Page, state> cleared on every main-frame
18
+ * navigation. This is the half that DETECTS staleness. Without it a ref
19
+ * from a previous page would merely fail to match, and the caller would
20
+ * be told "selector not found" instead of "take a new snapshot".
21
+ *
22
+ * So a stale ref fails loudly, with the reason and the fix (StaleRefError),
23
+ * and never silently hits the wrong element.
24
+ *
25
+ * Not to be confused with src/core/SnapshotManager.js, which is change-
26
+ * tracking history.
27
+ */
28
+
29
+ import { randomUUID } from 'node:crypto';
30
+
31
+ export const REF_ATTRIBUTE = 'data-cf-ref';
32
+ export const DEFAULT_MAX_NODES = 200;
33
+ export const MAX_NODES_LIMIT = 1000;
34
+
35
+ // Walks of one snapshot call, including the retry when the page navigates
36
+ // mid-walk. Two: one retry is enough for a page that settles, and a page
37
+ // navigating repeatedly is not one a snapshot can describe.
38
+ const MAX_WALK_ATTEMPTS = 2;
39
+
40
+ // Indentation follows the nesting of EMITTED nodes, and stops deepening past
41
+ // this many levels so a deep DOM cannot produce runaway leading whitespace.
42
+ const MAX_INDENT = 10;
43
+ const MAX_NAME_LENGTH = 120;
44
+
45
+ const REF_PATTERN = /^@e[1-9]\d*$/;
46
+
47
+ /** Thrown when a ref cannot be resolved against the page's current snapshot. */
48
+ export class StaleRefError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = 'StaleRefError';
52
+ }
53
+ }
54
+
55
+ // Page -> { snapshotId, refs, invalidated, tracking }. WeakMap so a closed
56
+ // page's refs go with it.
57
+ const pageState = new WeakMap();
58
+
59
+ /** True for an element ref (`@e1`), false for anything else, including non-strings. */
60
+ export function isRef(selector) {
61
+ return typeof selector === 'string' && REF_PATTERN.test(selector);
62
+ }
63
+
64
+ /**
65
+ * The injected walk. Fixed source, written by us — it is not caller-supplied
66
+ * JavaScript, so it has nothing to do with the ALLOW_JAVASCRIPT_EXECUTION flag
67
+ * that gates the `executeJavaScript` action. Do not put it behind that flag.
68
+ */
69
+ function snapshotScript({ refAttribute, interactiveOnly, maxNodes, maxIndent, maxNameLength }) {
70
+ const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'head']);
71
+ const INTERACTIVE_ROLES = new Set([
72
+ 'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'menuitem',
73
+ 'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'option', 'searchbox',
74
+ 'slider', 'spinbutton'
75
+ ]);
76
+ const STRUCTURAL_ROLES = new Set([
77
+ 'heading', 'banner', 'navigation', 'contentinfo', 'complementary', 'main',
78
+ 'form', 'region', 'search'
79
+ ]);
80
+ const LANDMARK_TAGS = {
81
+ main: 'main', nav: 'navigation', header: 'banner', footer: 'contentinfo',
82
+ aside: 'complementary', form: 'form'
83
+ };
84
+ const INPUT_ROLES = {
85
+ text: 'textbox', search: 'textbox', email: 'textbox', tel: 'textbox',
86
+ url: 'textbox', password: 'textbox', checkbox: 'checkbox', radio: 'radio',
87
+ submit: 'button', button: 'button', reset: 'button'
88
+ };
89
+
90
+ // Double quotes delimit the name in the tree, so a name may not contain one.
91
+ const clean = (value) => (value || '').replace(/\s+/g, ' ').trim().replace(/"/g, "'");
92
+ const truncate = (value) =>
93
+ (value.length > maxNameLength ? `${value.slice(0, maxNameLength - 1)}…` : value);
94
+
95
+ function roleOf(el, tag) {
96
+ const explicit = (el.getAttribute('role') || '').trim().toLowerCase();
97
+ if (explicit) return explicit.split(/\s+/)[0];
98
+ if (tag === 'input') return INPUT_ROLES[(el.getAttribute('type') || 'text').toLowerCase()] || tag;
99
+ if (tag === 'a') return el.hasAttribute('href') ? 'link' : tag;
100
+ if (tag === 'button' || tag === 'summary') return 'button';
101
+ if (tag === 'select') return 'combobox';
102
+ if (tag === 'textarea') return 'textbox';
103
+ if (/^h[1-6]$/.test(tag)) return 'heading';
104
+ return LANDMARK_TAGS[tag] || tag;
105
+ }
106
+
107
+ function nameOf(el, tag, allowTextContent) {
108
+ const ariaLabel = clean(el.getAttribute('aria-label'));
109
+ if (ariaLabel) return ariaLabel;
110
+
111
+ const labelledBy = (el.getAttribute('aria-labelledby') || '').trim();
112
+ if (labelledBy) {
113
+ const referenced = clean(labelledBy.split(/\s+/)
114
+ .map((id) => (document.getElementById(id) || {}).textContent || '')
115
+ .join(' '));
116
+ if (referenced) return referenced;
117
+ }
118
+
119
+ // el.labels covers both `label[for=id]` and an ancestor <label>; closest()
120
+ // is the fallback for elements that have no .labels (a contenteditable).
121
+ const labels = el.labels;
122
+ const labelText = clean(labels && labels.length
123
+ ? labels[0].textContent
124
+ : (el.closest('label') || {}).textContent);
125
+ if (labelText) return labelText;
126
+
127
+ for (const attribute of ['placeholder', 'title', 'alt']) {
128
+ const value = clean(el.getAttribute(attribute));
129
+ if (value) return value;
130
+ }
131
+
132
+ // A push button's label is its value; a text field's value is user data,
133
+ // not a name, which is why this is narrowed to the button types.
134
+ if (tag === 'input' && /^(button|submit|reset)$/.test((el.getAttribute('type') || '').toLowerCase())) {
135
+ const value = clean(el.value);
136
+ if (value) return value;
137
+ }
138
+
139
+ // A landmark is named by its label, never by everything inside it —
140
+ // otherwise <main> would be captioned with the whole page.
141
+ return allowTextContent ? clean(el.textContent) : '';
142
+ }
143
+
144
+ function isInteractive(el, tag, role) {
145
+ if (INTERACTIVE_ROLES.has(role)) return true;
146
+ if (tag === 'a') return el.hasAttribute('href');
147
+ if (tag === 'input') return (el.getAttribute('type') || '').toLowerCase() !== 'hidden';
148
+ if (tag === 'select' || tag === 'textarea' || tag === 'button' || tag === 'summary') return true;
149
+ const editable = el.getAttribute('contenteditable');
150
+ if (editable !== null && editable !== 'false') return true;
151
+ const tabindex = el.getAttribute('tabindex');
152
+ if (tabindex !== null && tabindex.trim() !== '-1') return true;
153
+ return el.hasAttribute('onclick');
154
+ }
155
+
156
+ function isHidden(el, tag) {
157
+ if (SKIP_TAGS.has(tag)) return true;
158
+ if (el.hasAttribute('hidden')) return true;
159
+ if (el.getAttribute('aria-hidden') === 'true') return true;
160
+ const style = getComputedStyle(el);
161
+ return style.display === 'none' || style.visibility === 'hidden';
162
+ }
163
+
164
+ const hasSize = (el) => {
165
+ const rect = el.getBoundingClientRect();
166
+ return rect.width > 0 && rect.height > 0;
167
+ };
168
+
169
+ const lines = [];
170
+ const refs = [];
171
+ let truncated = false;
172
+
173
+ function walk(el, depth) {
174
+ if (truncated) return;
175
+ const tag = el.tagName.toLowerCase();
176
+ if (isHidden(el, tag)) return; // the subtree is hidden with it
177
+
178
+ const role = roleOf(el, tag);
179
+ const interactive = isInteractive(el, tag, role);
180
+ const structural = !interactive && !interactiveOnly &&
181
+ (/^h[1-6]$/.test(tag) || Boolean(LANDMARK_TAGS[tag]) || STRUCTURAL_ROLES.has(role));
182
+ let childDepth = depth;
183
+
184
+ // A zero-size element is not emitted, but its children still are: a
185
+ // collapsed wrapper is common, an unreachable subtree is not.
186
+ if ((interactive || structural) && hasSize(el)) {
187
+ if (lines.length >= maxNodes) {
188
+ truncated = true;
189
+ return;
190
+ }
191
+ const name = truncate(nameOf(el, tag, interactive || role === 'heading'));
192
+ let ref = '';
193
+ // Only interactive nodes get a ref — a structural line is context, not a target.
194
+ if (interactive) {
195
+ const id = `e${refs.length + 1}`;
196
+ el.setAttribute(refAttribute, id);
197
+ refs.push({ id, role, name, tag });
198
+ ref = `@${id} `;
199
+ }
200
+ lines.push(`${' '.repeat(Math.min(depth, maxIndent))}${ref}[${role}]${name ? ` "${name}"` : ''}`);
201
+ childDepth = depth + 1;
202
+ }
203
+
204
+ for (const child of el.children) walk(child, childDepth);
205
+ }
206
+
207
+ // Drop refs from an earlier walk so a re-snapshot of the same page numbers
208
+ // cleanly instead of leaving elements answering to a retired id.
209
+ for (const stale of document.querySelectorAll(`[${refAttribute}]`)) {
210
+ stale.removeAttribute(refAttribute);
211
+ }
212
+ walk(document.body || document.documentElement, 1);
213
+
214
+ return { title: clean(document.title), lines, refs, truncated };
215
+ }
216
+
217
+ function stateFor(page) {
218
+ let state = pageState.get(page);
219
+ if (!state) {
220
+ state = { snapshotId: null, refs: null, invalidated: false, tracking: false, generation: 0 };
221
+ pageState.set(page, state);
222
+ }
223
+ return state;
224
+ }
225
+
226
+ /**
227
+ * Walk the page and return its tree plus the refs it assigned.
228
+ *
229
+ * @param {import('playwright').Page} page
230
+ * @param {object} [options]
231
+ * @param {boolean} [options.interactiveOnly=true] — false also emits headings and landmarks, unreffed
232
+ * @param {number} [options.maxNodes=200] — cap on emitted nodes, clamped to [1, MAX_NODES_LIMIT]
233
+ */
234
+ export async function captureSnapshot(page, options = {}) {
235
+ const interactiveOnly = options.interactiveOnly !== false;
236
+ const requested = Number(options.maxNodes);
237
+ const maxNodes = Number.isFinite(requested)
238
+ ? Math.min(Math.max(Math.floor(requested), 1), MAX_NODES_LIMIT)
239
+ : DEFAULT_MAX_NODES;
240
+
241
+ // Idempotent, and the guarantee that a later navigation invalidates these
242
+ // refs rather than leaving them to fail as a missing selector.
243
+ attachRefTracking(page);
244
+
245
+ const state = stateFor(page);
246
+ let title, lines, refs, truncated;
247
+
248
+ // A navigation that commits WHILE the walk is running would otherwise leave us
249
+ // holding refs for a document that has gone — the attributes were stamped on
250
+ // the old page, so `@e1` would match nothing and surface as a locator timeout
251
+ // instead of the named error D2 requires. `generation` moves on every
252
+ // main-frame navigation, so a change across the evaluate means exactly that.
253
+ // Walk the new document instead; if it navigates again, give up and leave the
254
+ // refs invalidated rather than publishing a tree for a page nobody is on.
255
+ for (let attempt = 0; ; attempt++) {
256
+ const generation = state.generation;
257
+ ({ title, lines, refs, truncated } = await page.evaluate(snapshotScript, {
258
+ refAttribute: REF_ATTRIBUTE,
259
+ interactiveOnly,
260
+ maxNodes,
261
+ maxIndent: MAX_INDENT,
262
+ maxNameLength: MAX_NAME_LENGTH
263
+ }));
264
+ if (state.generation === generation) break;
265
+ if (attempt >= MAX_WALK_ATTEMPTS - 1) {
266
+ clearRefs(page);
267
+ throw new StaleRefError(
268
+ 'The page navigated while the snapshot was being taken — take a new snapshot.'
269
+ );
270
+ }
271
+ }
272
+
273
+ const snapshotId = randomUUID().slice(0, 8);
274
+ state.snapshotId = snapshotId;
275
+ state.refs = new Map(refs.map(({ id, role, name, tag }) => [id, { role, name, tag }]));
276
+ state.invalidated = false;
277
+
278
+ return {
279
+ snapshotId,
280
+ url: page.url(),
281
+ title,
282
+ tree: [`[document]${title ? ` "${title}"` : ''}`, ...lines].join('\n'),
283
+ refCount: refs.length,
284
+ nodeCount: lines.length,
285
+ truncated,
286
+ interactiveOnly
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Turn `@e1` into the CSS selector every action path already understands.
292
+ * Throws StaleRefError when the ref does not belong to the page's current
293
+ * snapshot — it never guesses.
294
+ */
295
+ export function resolveRef(page, selector) {
296
+ if (!isRef(selector)) {
297
+ // A programming error, not a stale ref: callers gate on isRef().
298
+ throw new Error(`resolveRef expects an element ref like "@e1", got ${JSON.stringify(selector)}`);
299
+ }
300
+
301
+ const state = pageState.get(page);
302
+ if (!state || (!state.refs && !state.invalidated)) {
303
+ throw new StaleRefError(
304
+ `Unknown element ref ${selector}: no snapshot has been taken on this page — add a { "type": "snapshot" } action before acting on refs.`
305
+ );
306
+ }
307
+ if (!state.refs) {
308
+ throw new StaleRefError(
309
+ `Stale element ref ${selector}: the page navigated since the last snapshot — take a new snapshot before acting on refs.`
310
+ );
311
+ }
312
+
313
+ const id = selector.slice(1);
314
+ if (!state.refs.has(id)) {
315
+ const count = state.refs.size;
316
+ throw new StaleRefError(count === 0
317
+ ? `Unknown element ref ${selector}: the current snapshot has no refs — take a new snapshot.`
318
+ : `Unknown element ref ${selector}: the current snapshot has ${count} refs (@e1-@e${count}) — take a new snapshot.`);
319
+ }
320
+
321
+ return `[${REF_ATTRIBUTE}="${id}"]`;
322
+ }
323
+
324
+ /** Register the navigation listener that invalidates this page's refs. Idempotent. */
325
+ export function attachRefTracking(page) {
326
+ const state = stateFor(page);
327
+ if (state.tracking) return;
328
+ state.tracking = true;
329
+ page.on('framenavigated', (frame) => {
330
+ if (frame === page.mainFrame()) clearRefs(page);
331
+ });
332
+ }
333
+
334
+ /** Drop the page's refs. A ref resolved afterwards reports the navigation, not a miss. */
335
+ export function clearRefs(page) {
336
+ const state = pageState.get(page);
337
+ if (!state) return;
338
+ // `invalidated` is only meaningful once a snapshot existed — it is what
339
+ // separates "the page navigated" from "no snapshot has been taken".
340
+ if (state.refs) state.invalidated = true;
341
+ state.refs = null;
342
+ state.snapshotId = null;
343
+ // Bumped on every clear so a walk in flight can tell the document changed
344
+ // under it — see captureSnapshot.
345
+ state.generation++;
346
+ }
@@ -32,6 +32,7 @@ export const FALLBACK_HINTS = Object.freeze({
32
32
  extract_with_llm: 'If Ollama is unreachable pass provider:"openai" or "anthropic" with a key, or use extract_structured (CSS fallback needs no LLM).',
33
33
  list_ollama_models: 'Ollama is not reachable - use extract_with_llm with provider:"openai"/"anthropic", or extract_structured.',
34
34
  scrape_with_actions: 'Check the selector against scrape formats:["html"] output; for a one-shot render of a blocked page use stealth_mode operation:"scrape".',
35
+ browser_session: 'A session that expired or was closed cannot be reused - start again with operation:"open". If a ref missed, take another snapshot first: navigation invalidates refs. For a chain that needs no session, use scrape_with_actions.',
35
36
  deep_research: 'Use agent for a shorter answer, or search_web followed by scrape on the sources that matter.',
36
37
  scrape: 'After a 403/429/CAPTCHA/challenge page or an empty shell use stealth_mode operation:"scrape"; if the content needs a click or login use scrape_with_actions.',
37
38
  agent: 'Use deep_research for exhaustive sourcing, or search_web followed by scrape on the sources that matter.',
@@ -6,14 +6,18 @@
6
6
  * plumbing. AsyncLocalStorage bridges that gap: the transport runs each
7
7
  * request inside a context, and withAuth reads it at invocation time.
8
8
  *
9
- * Today the only flag is `internal`: a request authenticated with the
9
+ * The first flag is `internal`: a request authenticated with the
10
10
  * INTERNAL_PROXY_SECRET (the crawlforge-website REST proxy). Internal requests
11
11
  * run tools normally but are billing-exempt — the website has already checked
12
12
  * and charged the end user's credits, so metering here would double-bill.
13
13
  *
14
- * The flag lives on the request context, never on the MCP session: a session
15
- * id created by an internal request grants nothing to a later request that
16
- * authenticates by other means.
14
+ * The second is `ownerToken`, which says WHICH of the website's customers an
15
+ * internal request is being made for (see internalOwnerToken below).
16
+ *
17
+ * Both live on the request context, never on the MCP session: a session id
18
+ * created by an internal request grants nothing to a later request that
19
+ * authenticates by other means, and an owner established on one request is not
20
+ * inherited by the next one down the same MCP session.
17
21
  */
18
22
 
19
23
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -25,6 +29,23 @@ export function isInternalRequest() {
25
29
  return requestContext.getStore()?.internal === true;
26
30
  }
27
31
 
32
+ /**
33
+ * The end user this internal-proxy request is being made for, or null.
34
+ *
35
+ * An opaque per-user token the website derives with an HMAC keyed on the shared
36
+ * internal secret (mcpOwnerToken in crawlforge-website
37
+ * src/lib/tools/mcp-proxy.ts), carried on the X-CrawlForge-Owner header. It is
38
+ * not reversible to a user id here and is not meant to be: all a stateful tool
39
+ * needs is a value that is stable for one customer and distinct between them.
40
+ *
41
+ * Only ever set on a request that already proved the internal secret, and only
42
+ * after the transport has validated its shape — authenticateRequest in
43
+ * transports/streamableHttp.js is the single place that decides both.
44
+ */
45
+ export function internalOwnerToken() {
46
+ return requestContext.getStore()?.ownerToken ?? null;
47
+ }
48
+
28
49
  /**
29
50
  * Record that the compliance gate refused this invocation before anything was
30
51
  * fetched — robots.txt disallowed the path, or the host is on the permanent
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * toolFilter — client-side tool selection (Phase 6).
3
3
  *
4
- * Lets an MCP client load a subset of the 30 registered tools via env vars,
4
+ * Lets an MCP client load a subset of the 31 registered tools via env vars,
5
5
  * cutting context bloat (mirrors Bright Data / Exa's TOOLS / GROUPS pattern).
6
6
  *
7
7
  * Pure module: no I/O, no logging; process.env is only read via
@@ -17,7 +17,7 @@ export const TOOL_GROUPS = {
17
17
  search: ['search_web', 'serp_rank', 'reddit_search'],
18
18
  crawl: ['crawl_deep', 'map_site'],
19
19
  extract: ['extract_content', 'process_document', 'summarize_content', 'analyze_content', 'extract_structured', 'extract_with_llm', 'list_ollama_models', 'extract_embedded_state'],
20
- batch: ['batch_scrape', 'get_batch_results', 'scrape_with_actions'],
20
+ batch: ['batch_scrape', 'get_batch_results', 'scrape_with_actions', 'browser_session'],
21
21
  research: ['deep_research'],
22
22
  tracking: ['track_changes'],
23
23
  llmstxt: ['generate_llms_txt'],
@@ -314,9 +314,11 @@ export async function connectStreamableHttp(server, authManager, logger, options
314
314
  // — never expose an unauthenticated MCP endpoint on a public interface.
315
315
  // `internal` marks a request from the website's REST proxy
316
316
  // (INTERNAL_PROXY_SECRET): it is billing-exempt in withAuth because the
317
- // website already charged the end user. Request-scoped only never
318
- // persisted on the session.
317
+ // website already charged the end user. `ownerToken` says which of the
318
+ // website's customers it is being made for. Both are request-scoped only
319
+ // — never persisted on the session.
319
320
  let internal = false;
321
+ let ownerToken;
320
322
  if (!(authManager.isCreatorMode() && hostIsLoopback)) {
321
323
  const authResult = await authenticateRequest(req, authManager, oauthProvider);
322
324
  if (!authResult.ok) {
@@ -332,6 +334,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
332
334
  return;
333
335
  }
334
336
  internal = authResult.internal === true;
337
+ ownerToken = authResult.ownerToken;
335
338
  }
336
339
 
337
340
  // Era routing. Only a POST can carry the 2026-07-28 per-request envelope;
@@ -355,7 +358,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
355
358
  if (parsedBody !== undefined) {
356
359
  const probe = await toWebRequest(req, parsedBody);
357
360
  if (!(await isLegacyRequest(probe, parsedBody))) {
358
- await requestContext.run({ internal }, () => serveModern(req, res, parsedBody));
361
+ await requestContext.run({ internal, ownerToken }, () => serveModern(req, res, parsedBody));
359
362
  return;
360
363
  }
361
364
  }
@@ -370,7 +373,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
370
373
 
371
374
  if (existing) {
372
375
  await requestContext.run(
373
- { internal, servingServer: existing.server, servingEra: 'legacy' },
376
+ { internal, ownerToken, servingServer: existing.server, servingEra: 'legacy' },
374
377
  () => existing.transport.handleRequest(req, res, parsedBody)
375
378
  );
376
379
  return;
@@ -406,7 +409,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
406
409
  try {
407
410
  await sessionServer.connect(transport);
408
411
  await requestContext.run(
409
- { internal, servingServer: sessionServer, servingEra: 'legacy' },
412
+ { internal, ownerToken, servingServer: sessionServer, servingEra: 'legacy' },
410
413
  () => transport.handleRequest(req, res, parsedBody)
411
414
  );
412
415
  } catch (err) {
@@ -453,6 +456,28 @@ export async function connectStreamableHttp(server, authManager, logger, options
453
456
  };
454
457
  }
455
458
 
459
+ /**
460
+ * Hex, and bounded. The website emits 32 characters (mcpOwnerToken in
461
+ * crawlforge-website src/lib/tools/mcp-proxy.ts); the range is wider so the two
462
+ * repos can pick a different HMAC slice without a lockstep deploy, and narrow
463
+ * enough that nothing unbounded, non-printable or structured can ever become
464
+ * part of an owner id.
465
+ */
466
+ const OWNER_TOKEN_RE = /^[0-9a-f]{16,64}$/;
467
+
468
+ /**
469
+ * The owner token an internal request claims, or undefined.
470
+ *
471
+ * Undefined covers absent AND malformed alike, and the difference must not
472
+ * matter: everything downstream treats "no owner" as "no session", so a value
473
+ * that fails this check is simply not an owner rather than a strange one. Never
474
+ * relax this into a coercion — the token becomes part of a tenant key.
475
+ */
476
+ function readOwnerToken(req) {
477
+ const value = (req.headers['x-crawlforge-owner'] || '').toString();
478
+ return OWNER_TOKEN_RE.test(value) ? value : undefined;
479
+ }
480
+
456
481
  /**
457
482
  * Validate a request's credentials.
458
483
  *
@@ -460,13 +485,14 @@ export async function connectStreamableHttp(server, authManager, logger, options
460
485
  * - `X-Internal-Secret: <INTERNAL_PROXY_SECRET>` — server-to-server requests
461
486
  * from the crawlforge-website REST proxy. Returns { ok, internal: true };
462
487
  * internal requests are billing-exempt in withAuth (the website already
463
- * charged the end user). Only active when the env var is set.
488
+ * charged the end user). Only active when the env var is set. Such a
489
+ * request may also carry `X-CrawlForge-Owner` — see readOwnerToken.
464
490
  * - `Authorization: Bearer <crawlforge-api-key>` (legacy static key)
465
491
  * - `X-API-Key: <crawlforge-api-key>` (legacy static key)
466
492
  * - `Authorization: Bearer <oauth-access-token>` if OAuth is enabled —
467
493
  * the OAuth provider validates the token and maps it to the API key.
468
494
  *
469
- * @returns {Promise<{ok: true, internal?: boolean} | {ok: false, status: number, error: string, message: string, reason: string}>}
495
+ * @returns {Promise<{ok: true, internal?: boolean, ownerToken?: string} | {ok: false, status: number, error: string, message: string, reason: string}>}
470
496
  */
471
497
  async function authenticateRequest(req, authManager, oauthProvider) {
472
498
  // Internal proxy path first: presenting the header at all means the caller
@@ -480,7 +506,11 @@ async function authenticateRequest(req, authManager, oauthProvider) {
480
506
  const provided = createHash('sha256').update(providedSecret).digest();
481
507
  const expected = createHash('sha256').update(internalSecret).digest();
482
508
  if (timingSafeEqual(provided, expected)) {
483
- return { ok: true, internal: true };
509
+ // Read ONLY here, on the branch that has just proved the secret. A
510
+ // request that authenticated any other way — or none — never has its
511
+ // owner header looked at, so claiming an owner requires already being
512
+ // the proxy.
513
+ return { ok: true, internal: true, ownerToken: readOwnerToken(req) };
484
514
  }
485
515
  }
486
516
  return {
@@ -97,13 +97,20 @@ browser actions before extraction.
97
97
  }
98
98
  ```
99
99
 
100
- Allowed action types: `wait`, `click`, `type`, `press`, `scroll`, `screenshot`,
101
- `executeJavaScript`. `executeJavaScript` is disabled unless the deploy sets
100
+ Allowed action types: `snapshot`, `wait`, `click`, `type`, `press`, `scroll`,
101
+ `screenshot`, `executeJavaScript`, `select`, `hover`, `navigate`. Start a chain
102
+ with `{"type": "snapshot"}` to list the page's interactive elements with stable
103
+ refs (`@e1`, `@e2` …) and target those in later actions instead of guessing CSS
104
+ selectors. `executeJavaScript` is disabled unless the deploy sets
102
105
  `ALLOW_JAVASCRIPT_EXECUTION=true`. 1–20 actions per call. Screenshots are stored
103
106
  as `crawlforge://screenshot/{actionId}` resources. Full action schemas:
104
107
  [actions](references/actions.md). CLI:
105
108
  `crawlforge actions https://example.com --script login.json --screenshot`.
106
109
 
110
+ One-shot: the browser closes when the call returns. When the flow spans more
111
+ than one call, or you need to see the page before choosing what to click, use
112
+ `browser_session` instead (crawlforge-browser-sessions).
113
+
107
114
  ## generate_llms_txt — AI policy file (cost: 5)
108
115
 
109
116
  ```json
@@ -1,10 +1,16 @@
1
1
  # scrape_with_actions — Action Types
2
2
 
3
3
  `scrape_with_actions` runs an ordered `actions[]` array (1–20 items) before
4
- scraping. Only these 7 action types are allowed (allow-listed in ActionExecutor).
5
- Each action object has a `type` plus type-specific fields. Common optional
6
- fields on every action: `timeout`, `description`, `continueOnError`, `retries`
7
- (0–5), `captureAfter`.
4
+ scraping. Only these action types are allowed (allow-listed in ActionExecutor):
5
+ `snapshot`, `wait`, `click`, `type`, `press`, `scroll`, `screenshot`,
6
+ `executeJavaScript`, `select`, `hover`, `navigate`. Each action object has a
7
+ `type` plus type-specific fields. Common optional fields on every action:
8
+ `timeout`, `description`, `continueOnError`, `retries` (0–5), `captureAfter`.
9
+
10
+ **Start with a snapshot.** Section 8 returns the page's interactive elements
11
+ with stable refs, and any action's `selector` may name one (`@e1`) instead of a
12
+ CSS selector you have not seen. That is the difference between landing a
13
+ multi-step flow first try and burning the call on a guess.
8
14
 
9
15
  ## 1. wait
10
16
 
@@ -101,6 +107,46 @@ Disabled unless the deployment sets `ALLOW_JAVASCRIPT_EXECUTION=true`.
101
107
  { "type": "executeJavaScript", "script": "return document.title", "returnResult": true }
102
108
  ```
103
109
 
110
+ ## 8. snapshot
111
+
112
+ List the page's interactive elements, each with a stable ref later actions can
113
+ target. Refs are assigned `@e1…@eN` in document order and are **invalidated by
114
+ navigation** — snapshot again after one, or acting on an old ref fails with a
115
+ named error telling you to.
116
+
117
+ | Field | Type | Notes |
118
+ |-------|------|-------|
119
+ | `interactiveOnly` | boolean | Default true. False also lists headings and landmarks, which carry no ref. |
120
+ | `maxNodes` | number | Cap on nodes listed (default 200, max 1000). The result sets `truncated` when the cap stopped the walk. |
121
+
122
+ ```json
123
+ { "type": "snapshot" }
124
+ ```
125
+
126
+ The tree comes back in `actionResults[i].result.tree`, alongside `snapshotId`,
127
+ `url`, `title`, `refCount`, `nodeCount`, `truncated` and `interactiveOnly`:
128
+
129
+ ```
130
+ [document] "Sign in"
131
+ @e1 [textbox] "Email"
132
+ @e2 [textbox] "Password"
133
+ @e3 [button] "Sign in"
134
+ ```
135
+
136
+ ```json
137
+ [
138
+ { "type": "snapshot" },
139
+ { "type": "type", "selector": "@e1", "text": "user@example.com" },
140
+ { "type": "click", "selector": "@e3" }
141
+ ]
142
+ ```
143
+
144
+ The tool is stateless, so a chain cannot adapt to its own snapshot mid-flight:
145
+ read the refs in one call, act on them in the next. A fresh load of the same
146
+ page numbers them the same way, and the second chain snapshots again first so
147
+ the refs are stamped on the document it is acting against. The walk covers the
148
+ main frame only — elements inside iframes and shadow DOM get no refs.
149
+
104
150
  ## Top-level options
105
151
 
106
152
  | Option | Default | Notes |