@enricai/barnacle 1.4.1 → 1.5.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.
@@ -20,20 +20,38 @@ Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.resolveStepPayloadField = resolveStepPayloadField;
21
21
  exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
22
22
  exports.selectPayloadAction = selectPayloadAction;
23
+ exports.extractActionSequence = extractActionSequence;
24
+ exports.indexStateValues = indexStateValues;
25
+ exports.compileActionSteps = compileActionSteps;
23
26
  exports.loadQuestionPromptKeywords = loadQuestionPromptKeywords;
24
27
  exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
25
28
  exports.emitContractTs = emitContractTs;
26
29
  exports.emitConfigManifest = emitConfigManifest;
27
30
  exports.emitBrowserFlowTs = emitBrowserFlowTs;
31
+ exports.emitIndexTs = emitIndexTs;
28
32
  const node_fs_1 = require("node:fs");
29
33
  const node_path_1 = require("node:path");
30
34
  const errors_1 = require("../lib/errors");
31
35
  const logging_1 = require("../lib/logging");
36
+ const plugin_api_version_1 = require("../plugins/plugin-api-version");
32
37
  const plugin_manifest_envelope_1 = require("../plugins/plugin-manifest-envelope");
33
38
  const load_vocabulary_1 = require("../recon/load-vocabulary");
34
39
  const vocabulary_1 = require("../recon/vocabulary");
35
40
  const recon_shared_1 = require("../scripts/recon-shared");
36
41
  const logger = (0, logging_1.getScriptLogger)("recon-generate");
42
+ /**
43
+ * Engine imports in GENERATED code must be package subpaths, never the `@/`
44
+ * alias, and the reason is not visible from the source: `tsc-alias` rewrites by
45
+ * text, so it cannot tell an import this module *uses* from one it *emits as a
46
+ * string*. Written as `@/scraper/session`, the build silently rewrote the
47
+ * template literal itself — shipping `dist/` emitters that generated
48
+ * `../scraper/session` and left every out-of-tree consumer with TS2307.
49
+ * (`@/sites/...` survived only because `src/sites/` is empty, so it resolved to
50
+ * no file.) A bare specifier has nothing to resolve against, so the build leaves
51
+ * it alone. `out-of-tree-e2e.test.ts` asserts this against the BUILT dist —
52
+ * asserting it against the source would pass while the shipped artifact is broken.
53
+ */
54
+ const ENGINE_PKG = "@enricai/barnacle";
37
55
  // ── helpers ──────────────────────────────────────────────────────────────────
38
56
  function toPascalCase(siteId) {
39
57
  return siteId
@@ -406,9 +424,29 @@ const TELEMETRY_URL_PATTERNS = [
406
424
  .map((p) => p.trim())
407
425
  .filter(Boolean),
408
426
  ];
427
+ /**
428
+ * A POST to a path whose own segment is `error`/`errors` is a client-side
429
+ * reporting sink, never a call a caller wants replayed.
430
+ *
431
+ * Emitting one is worse than noise. A browser's error reports are frozen at
432
+ * recon time, so the generated plugin re-POSTs a crash that never happened —
433
+ * a stack trace and timestamp from the recon run, sent to the site on every
434
+ * invocation, describing a failure in a page the plugin never loaded.
435
+ *
436
+ * Matched on a whole path segment rather than by substring so `/error-codes`
437
+ * and `/terrorism-screening` stay data endpoints, and kept out of
438
+ * TELEMETRY_URL_PATTERNS because that list is literal substrings — a site's own
439
+ * sink is structural, not an ad-tech domain the operator must enumerate.
440
+ */
441
+ const ERROR_SINK_PATH_SEGMENT = /(^|\/)errors?(\/|$)/i;
409
442
  /**
410
443
  * Extracts the ordered sequence of meaningful POSTs that represent the
411
- * transactional flow. Filters out GETs, telemetry, asset hits, and non-2xx.
444
+ * transactional flow: same-host 2xx POSTs, minus telemetry and error-reporting
445
+ * sinks. Assets need no filter of their own — they arrive as GETs.
446
+ *
447
+ * Exported for tests: this predicate decides what a generated plugin will POST
448
+ * at a live site, and it is the only gate between a browser's incidental
449
+ * chatter and the emitted hot path.
412
450
  */
413
451
  function extractActionSequence(captures, baseUrl) {
414
452
  let host;
@@ -436,6 +474,13 @@ function extractActionSequence(captures, baseUrl) {
436
474
  return false;
437
475
  if (TELEMETRY_URL_PATTERNS.some((p) => capture.url.includes(p)))
438
476
  return false;
477
+ try {
478
+ if (ERROR_SINK_PATH_SEGMENT.test(new URL(capture.url).pathname))
479
+ return false;
480
+ }
481
+ catch {
482
+ return false;
483
+ }
439
484
  return true;
440
485
  });
441
486
  }
@@ -663,13 +708,16 @@ function detectFormSchemaFieldNames(captures) {
663
708
  return { fieldNameMap, fieldOptionsMap, allSchemaUuids };
664
709
  }
665
710
  /**
666
- * Walks a response body collecting every UUID-shaped string that appears at
667
- * a `FieldId` or `OptionId` (and its lowercase variants) path leaf, OR as the
668
- * `Id` of an object that has `OptionSourceCode`/`StringKey`/`FieldOptions`
669
- * sibling — i.e. an OptionId in the form schema. These UUIDs are stable
711
+ * Walks a response body collecting UUID-shaped strings under a `FieldId` key, or
712
+ * under the `Id` of an entry in a sibling `FieldOptions` array. These are stable
670
713
  * schema anchors that must be shielded from state-threading even when
671
- * detectFormSchemaFieldNames doesn't emit a payload-mappable name for the
672
- * field (e.g. when the field's FieldName is too long for our naming heuristic).
714
+ * detectFormSchemaFieldNames emits no payload-mappable name for the field (e.g.
715
+ * when the field's FieldName is too long for our naming heuristic).
716
+ *
717
+ * The key names are exact by design, not an oversight: a differing wire format
718
+ * (a lowercase `fieldId`, another vendor's option key) is the consumer's to
719
+ * declare, not the engine's to guess — see issue #57. Matching case variants
720
+ * here would re-broaden the very fingerprint that issue exists to narrow.
673
721
  */
674
722
  function walkForSchemaUuids(value, out) {
675
723
  if (value === null || typeof value !== "object")
@@ -1057,12 +1105,35 @@ const MAX_STATE_VALUE_LENGTH = 256;
1057
1105
  * does not exist". Closed set, literal-string match — never expand to
1058
1106
  * pattern-based detection (would trip the no-regex-on-open-sets rule). */
1059
1107
  const PLACEHOLDER_STATE_VALUES = new Set(["00000000-0000-0000-0000-000000000000"]);
1108
+ /**
1109
+ * Splits a raw `Set-Cookie` response-header string into `name`/`value` pairs.
1110
+ * Captures store `responseHeaders` as a flat `Record<string, string>`
1111
+ * (see recon-shared.ts's `Capture`), so multiple `Set-Cookie` headers from the
1112
+ * same response — if the recon browser's CDP session folds them together —
1113
+ * would already have lost their individual boundaries before reaching here;
1114
+ * this only recovers name/value pairs from whatever single string survives.
1115
+ */
1116
+ function* walkSetCookiePairs(rawSetCookie) {
1117
+ const pair = rawSetCookie.split(";", 1)[0] ?? "";
1118
+ const eq = pair.indexOf("=");
1119
+ if (eq === -1)
1120
+ return;
1121
+ const name = pair.slice(0, eq).trim();
1122
+ const value = pair.slice(eq + 1).trim();
1123
+ if (name && value)
1124
+ yield { name, value };
1125
+ }
1060
1126
  /**
1061
1127
  * Walks every capture's response (including GETs — formHistoryId-style values
1062
1128
  * may originate in a state-load GET, not a POST). Indexes every string leaf
1063
1129
  * whose length is in [MIN, MAX], recording the EARLIEST capture index that
1064
1130
  * produced it. Later occurrences of the same value reuse the earliest origin.
1065
1131
  *
1132
+ * Also indexes response-header/cookie-origin values (e.g. a `Set-Cookie`
1133
+ * auth token) the same way, tagged with `headerOrigin` instead of a body
1134
+ * `path` — this is what lets a stateful API's token-mint response feed a
1135
+ * later call's `Cookie` header via `compileActionSteps`.
1136
+ *
1066
1137
  * The index is intentionally permissive — it doesn't try to shape-match
1067
1138
  * "what looks like a token" because token shapes are an open set across the
1068
1139
  * web. Authoritative filtering happens downstream in `compileActionSteps`,
@@ -1073,6 +1144,8 @@ const PLACEHOLDER_STATE_VALUES = new Set(["00000000-0000-0000-0000-000000000000"
1073
1144
  * the LATER non-placeholder occurrence at the same JSON path becomes the
1074
1145
  * canonical binding instead.
1075
1146
  */
1147
+ /** Exported for unit testing — lets tests exercise the produces[] walk (body
1148
+ * AND header/cookie origins) directly against synthetic Capture sequences. */
1076
1149
  function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set()) {
1077
1150
  const index = new Map();
1078
1151
  // First pass: identify the earliest origin among ACTION captures for each
@@ -1085,10 +1158,32 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
1085
1158
  const haveActionFilter = actionCaptureIndices.size > 0;
1086
1159
  for (let i = 0; i < captures.length; i++) {
1087
1160
  const c = captures[i];
1088
- if (c.responseBody === undefined || c.responseBody === null)
1089
- continue;
1090
1161
  if (haveActionFilter && !actionCaptureIndices.has(i))
1091
1162
  continue;
1163
+ // Headers/cookies are indexed regardless of responseBody presence — a
1164
+ // token-mint call like disneycruise's `authz/private` returns `{}` and
1165
+ // carries its whole payload in `Set-Cookie`.
1166
+ const rawSetCookie = Object.entries(c.responseHeaders).find(([k]) => k.toLowerCase() === "set-cookie")?.[1];
1167
+ if (rawSetCookie !== undefined) {
1168
+ for (const { name, value } of walkSetCookiePairs(rawSetCookie)) {
1169
+ if (value.length < MIN_STATE_VALUE_LENGTH)
1170
+ continue;
1171
+ if (value.length > MAX_STATE_VALUE_LENGTH)
1172
+ continue;
1173
+ if (PLACEHOLDER_STATE_VALUES.has(value))
1174
+ continue;
1175
+ if (!index.has(value)) {
1176
+ index.set(value, {
1177
+ value,
1178
+ originIndex: i,
1179
+ path: [],
1180
+ headerOrigin: { sourceHeader: "set-cookie", cookieName: name },
1181
+ });
1182
+ }
1183
+ }
1184
+ }
1185
+ if (c.responseBody === undefined || c.responseBody === null)
1186
+ continue;
1092
1187
  // For GET captures, only index UUID-shaped strings. GET captures (today,
1093
1188
  // only the form-schema fetch inserted as an action step) surface stable
1094
1189
  // structural identifiers — UUIDs that downstream POSTs need to thread.
@@ -1125,9 +1220,18 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
1125
1220
  function isValidJsIdentifier(s) {
1126
1221
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
1127
1222
  }
1128
- /** Converts a path like ["Auth","Token"] to a JS access expression ".Auth.Token". */
1223
+ /**
1224
+ * Converts a path like ["Auth","Token"] to a JS access expression ".Auth.Token".
1225
+ * Bracket segments (numeric array indices, non-identifier keys) get a trailing
1226
+ * `!` — under `noUncheckedIndexedAccess` an array/index-signature access types
1227
+ * as `T | undefined`, and this accessor is only ever used against a real Zod-
1228
+ * inferred array/object type (payload fields, captured response bodies), never
1229
+ * against the object-literal assertion types `pathToAssertionType` builds (those
1230
+ * use known string-literal keys, which `noUncheckedIndexedAccess` does not
1231
+ * widen). Dot segments stay bare since object property access isn't affected.
1232
+ */
1129
1233
  function pathToAccessor(path) {
1130
- return path.map((p) => (isValidJsIdentifier(p) ? `.${p}` : `[${JSON.stringify(p)}]`)).join("");
1234
+ return path.map((p) => (isValidJsIdentifier(p) ? `.${p}` : `[${JSON.stringify(p)}]!`)).join("");
1131
1235
  }
1132
1236
  /**
1133
1237
  * Builds a nested TypeScript assertion type matching a JSON path. e.g.
@@ -1161,26 +1265,75 @@ function pathToVarName(path) {
1161
1265
  * var name, the state values its response produces (used by downstream steps),
1162
1266
  * and a multipart flag (request body bytes not captured).
1163
1267
  */
1268
+ /** Exported for unit testing — see `indexStateValues`. */
1164
1269
  function compileActionSteps(actions, stateIndex) {
1165
1270
  const usedValues = new Set();
1271
+ // Maps a used state value to the request-header NAME that carries it, for
1272
+ // values whose consuming reference is a request header (not the URL/body).
1273
+ // A header-origin produce needs this as its `targetHeader` — the header the
1274
+ // *next* httpClient call must send the bound value back on. Only the first
1275
+ // consuming header name observed wins; a value used in more than one distinct
1276
+ // header downstream isn't a shape this models (see http-client.ts's `bind`,
1277
+ // which is single-target per binding).
1278
+ const usedValueTargetHeader = new Map();
1166
1279
  // Pre-scan: collect all state values referenced by ANY action's URL/headers/body
1167
1280
  // so we only "produce" the values that are actually consumed downstream.
1168
1281
  for (const { capture } of actions) {
1169
1282
  const haystacks = [capture.url];
1170
- for (const v of Object.values(capture.requestHeaders))
1171
- haystacks.push(v);
1172
1283
  if (capture.requestPostData)
1173
1284
  haystacks.push(capture.requestPostData);
1174
1285
  for (const sv of stateIndex.values()) {
1175
1286
  if (haystacks.some((h) => h.includes(sv.value)))
1176
1287
  usedValues.add(sv.value);
1177
1288
  }
1289
+ for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
1290
+ for (const sv of stateIndex.values()) {
1291
+ if (!headerValue.includes(sv.value))
1292
+ continue;
1293
+ usedValues.add(sv.value);
1294
+ if (!usedValueTargetHeader.has(sv.value)) {
1295
+ usedValueTargetHeader.set(sv.value, headerName);
1296
+ }
1297
+ }
1298
+ }
1178
1299
  }
1179
1300
  let lastHost = null;
1180
1301
  return actions.map(({ capture, index }, i) => {
1181
1302
  const varName = `r${i}`;
1182
1303
  const produces = [];
1183
1304
  const seenNames = new Set();
1305
+ // Header/cookie-origin produces — walked first so a value that appears in
1306
+ // BOTH a Set-Cookie and the JSON body (unlikely, but not ruled out) prefers
1307
+ // the header binding, which is what the runtime actually threads.
1308
+ const rawSetCookie = Object.entries(capture.responseHeaders).find(([k]) => k.toLowerCase() === "set-cookie")?.[1];
1309
+ if (rawSetCookie !== undefined) {
1310
+ for (const { name: cookieName, value } of walkSetCookiePairs(rawSetCookie)) {
1311
+ if (!usedValues.has(value))
1312
+ continue;
1313
+ const sv = stateIndex.get(value);
1314
+ if (!sv || sv.originIndex !== index || !sv.headerOrigin)
1315
+ continue;
1316
+ const targetHeader = usedValueTargetHeader.get(value);
1317
+ if (!targetHeader)
1318
+ continue;
1319
+ let name = `${cookieName.replace(/[^A-Za-z0-9]/g, "")}Cookie`;
1320
+ if (!/^[A-Za-z_$]/.test(name))
1321
+ name = `_${name}`;
1322
+ let suffix = 1;
1323
+ while (seenNames.has(name)) {
1324
+ suffix++;
1325
+ name = `${cookieName.replace(/[^A-Za-z0-9]/g, "")}Cookie${suffix}`;
1326
+ }
1327
+ seenNames.add(name);
1328
+ produces.push({
1329
+ kind: "header",
1330
+ name,
1331
+ sourceHeader: sv.headerOrigin.sourceHeader,
1332
+ cookieName: sv.headerOrigin.cookieName,
1333
+ targetHeader,
1334
+ });
1335
+ }
1336
+ }
1184
1337
  if (capture.responseBody !== undefined && capture.responseBody !== null) {
1185
1338
  for (const { value, path } of walkStringLeaves(capture.responseBody)) {
1186
1339
  if (!usedValues.has(value))
@@ -1196,7 +1349,7 @@ function compileActionSteps(actions, stateIndex) {
1196
1349
  name = `${pathToVarName(path)}${suffix}`;
1197
1350
  }
1198
1351
  seenNames.add(name);
1199
- produces.push({ name, pathExpr: `${varName}${pathToAccessor(path)}`, path });
1352
+ produces.push({ kind: "body", name, pathExpr: `${varName}${pathToAccessor(path)}`, path });
1200
1353
  }
1201
1354
  }
1202
1355
  const ct = Object.entries(capture.requestHeaders).find(([k]) => k.toLowerCase() === "content-type");
@@ -1213,6 +1366,27 @@ function compileActionSteps(actions, stateIndex) {
1213
1366
  return { capture, varName, produces, isMultipart, isCrossDomain };
1214
1367
  });
1215
1368
  }
1369
+ /**
1370
+ * Collects every header/cookie-origin produce across an action sequence, in
1371
+ * step order — this is what `emitContractTs` renders as `createHttpClient`'s
1372
+ * `bind` option so the generated `executeHttp` actually forwards a value like
1373
+ * disneycruise's `Set-Cookie: __pa=<jwt>` mint to the stateful call that 401s
1374
+ * without it. Deduped by `targetHeader`: `HttpResponseBinding` (http-client.ts)
1375
+ * is one binding per target header, so if two steps somehow produced the same
1376
+ * target the earliest wins.
1377
+ */
1378
+ function collectHeaderBindings(actionSteps) {
1379
+ const byTarget = new Map();
1380
+ for (const step of actionSteps) {
1381
+ for (const p of step.produces) {
1382
+ if (p.kind !== "header")
1383
+ continue;
1384
+ if (!byTarget.has(p.targetHeader))
1385
+ byTarget.set(p.targetHeader, p);
1386
+ }
1387
+ }
1388
+ return [...byTarget.values()];
1389
+ }
1216
1390
  /**
1217
1391
  * Replaces occurrences of state values in `template` with `${varName}`
1218
1392
  * interpolations. Returns a JS template-literal string fragment (no backticks).
@@ -1227,6 +1401,12 @@ function interpolateStateValues(template, priorSteps, payloadAccessorByValue = n
1227
1401
  const varNameByValue = new Map();
1228
1402
  for (const step of priorSteps) {
1229
1403
  for (const p of step.produces) {
1404
+ // Header/cookie-origin produces have no body path — their value never
1405
+ // appears as a literal in a URL/body template (http-client's `bind`
1406
+ // forwards it directly as a request header), so there's nothing to
1407
+ // interpolate here.
1408
+ if (p.kind === "header")
1409
+ continue;
1230
1410
  let cursor = step.capture.responseBody;
1231
1411
  for (const segment of p.path) {
1232
1412
  if (cursor !== null &&
@@ -1820,6 +2000,11 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1820
2000
  }
1821
2001
  }
1822
2002
  for (const p of step.produces) {
2003
+ // Header/cookie-origin produces never surface as a JS accessor —
2004
+ // createHttpClient's `bind` option (rendered once, above the steps)
2005
+ // captures and forwards the value internally.
2006
+ if (p.kind === "header")
2007
+ continue;
1823
2008
  if (declaredNames.has(p.name))
1824
2009
  continue;
1825
2010
  if (!referencedNames.has(p.name))
@@ -1845,14 +2030,40 @@ function summariseResponseShape(value) {
1845
2030
  }
1846
2031
  return obj;
1847
2032
  }
2033
+ /**
2034
+ * Renders `headerBindings` as a trailing `, bind: [...]` fragment for
2035
+ * `createHttpClient`'s options object literal — empty string when there are
2036
+ * none, so a plugin with no header/cookie-origin state keeps the exact output
2037
+ * this emitter already produced. Structurally matches `HttpResponseBinding`
2038
+ * (http-client.ts) without importing the type: the object literal typechecks
2039
+ * against `HttpClientOptions.bind` on its own shape.
2040
+ */
2041
+ function bindOptionLiteral(headerBindings) {
2042
+ if (headerBindings.length === 0)
2043
+ return "";
2044
+ const entries = headerBindings
2045
+ .map((b) => {
2046
+ const cookieNameField = b.cookieName !== undefined ? ` cookieName: ${JSON.stringify(b.cookieName)},` : "";
2047
+ return `{ sourceHeader: ${JSON.stringify(b.sourceHeader)},${cookieNameField} targetHeader: ${JSON.stringify(b.targetHeader)} }`;
2048
+ })
2049
+ .join(", ");
2050
+ return `, bind: [${entries}]`;
2051
+ }
1848
2052
  // ── code emitters ─────────────────────────────────────────────────────────────
1849
2053
  /** Generates a complete contract.ts source string for a plugin — exported so
1850
2054
  * unit tests can drive the emitter directly without spawning the CLI. */
1851
2055
  function emitContractTs(opts) {
1852
- const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, auxFiles, multiStepBody, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, payloadFieldNames, base64ContentHelper = "", } = opts;
2056
+ const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, auxFiles, multiStepBody, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, payloadFieldNames, base64ContentHelper = "", headerBindings = [], } = opts;
1853
2057
  // Multi-step plugins thread responses through many different shapes that a
1854
2058
  // single Zod schema can't cover — use z.unknown() so each per-step access
1855
2059
  // compiles cleanly. Single-endpoint plugins keep the inferred schema.
2060
+ //
2061
+ // This is deliberate, not an unfinished schema: a submission flow's terminal
2062
+ // shape is the plugin's OWN contract with its caller (e.g. { verified: boolean }),
2063
+ // a field that appears in zero captured responses. Inferring a schema from the
2064
+ // captures would emit the wrong shape with false confidence. z.unknown() plus
2065
+ // the generated `[ ] Narrow ResponseSchema` checklist item is the intended
2066
+ // hand-off to the plugin author, who alone knows that contract.
1856
2067
  const responseSchemaExpr = multiStepBody ? `z.unknown()` : inferZodSchema(responseBody);
1857
2068
  // Multi-step flows that include a multipart upload need the binary asset
1858
2069
  // on the payload. Add Resume/ResumeContentType/ResumeFilename as required
@@ -1965,24 +2176,24 @@ function emitContractTs(opts) {
1965
2176
  ? `${basePayloadSchemaExpr}.extend({\n Resume: z.instanceof(Buffer),\n ResumeContentType: z.string(),\n ResumeFilename: z.string(),\n})${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${answersExtension}`
1966
2177
  : `${basePayloadSchemaExpr}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${answersExtension}`;
1967
2178
  // When the payload schema uses multipartBoolean(), import the shared helper
1968
- // from @/lib/zod-multipart so the generated file resolves the reference and
1969
- // doesn't re-inline the preprocess expression per boolean field.
2179
+ // so the generated file resolves the reference and doesn't re-inline the
2180
+ // preprocess expression per boolean field.
1970
2181
  const multipartBoolImport = hasMultipartStep
1971
- ? `import { multipartBoolean } from "../lib/zod-multipart";\n`
2182
+ ? `import { multipartBoolean } from "${ENGINE_PKG}/lib/zod-multipart";\n`
1972
2183
  : "";
1973
2184
  // Content-Type must be absent from multipart fetch calls so FormData can inject the boundary.
1974
2185
  const caseInsensitiveHeadersImport = hasMultipartStep
1975
- ? `import { omitHeaderCaseInsensitive } from "../lib/case-insensitive-headers";\n`
2186
+ ? `import { omitHeaderCaseInsensitive } from "${ENGINE_PKG}/lib/case-insensitive-headers";\n`
1976
2187
  : "";
1977
2188
  // Emit identifier-shaped keys unquoted so Biome's formatter doesn't rewrite
1978
2189
  // the generated file on first lint:fix.
1979
2190
  const headersLiteral = Object.entries(baseHeaders)
1980
2191
  .map(([k, v]) => ` ${isValidJsIdentifier(k) ? k : JSON.stringify(k)}: ${JSON.stringify(v)}`)
1981
2192
  .join(",\n");
1982
- const fixtureImport = auxFiles.length > 0 ? `// import { loadFixture } from "../scraper/fixtures";\n` : "";
2193
+ const fixtureImport = auxFiles.length > 0 ? `// import { loadFixture } from "${ENGINE_PKG}/scraper/fixtures";\n` : "";
1983
2194
  const clientImport = gql
1984
- ? `import { createGraphqlClient } from "../scraper/graphql-client";`
1985
- : `import { createHttpClient } from "../scraper/http-client";`;
2195
+ ? `import { createGraphqlClient } from "${ENGINE_PKG}/scraper/graphql-client";`
2196
+ : `import { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";`;
1986
2197
  const queryConst = gql && gqlQuery
1987
2198
  ? `\n// Lifted verbatim from recon capture — trim UI-only fields before shipping.\nconst ${pascal.toUpperCase()}_QUERY = \`${gqlQuery.trim()}\`;\n`
1988
2199
  : "";
@@ -2007,7 +2218,7 @@ function getGql(baseUrl: string): GqlFn {
2007
2218
  }
2008
2219
  `
2009
2220
  : `
2010
- const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottleneck: limiter, baseHeaders: BASE_HEADERS });
2221
+ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)} });
2011
2222
  `;
2012
2223
  const executeHttpBody = multiStepBody
2013
2224
  ? multiStepBody
@@ -2029,6 +2240,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
2029
2240
  const queryChecklistLine = gql
2030
2241
  ? `\n * [ ] Trim UI-only fields from ${pascal.toUpperCase()}_QUERY (keep only fields you need)`
2031
2242
  : "";
2243
+ const camel = siteId.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2032
2244
  return `/**
2033
2245
  * Generated by recon-generate.ts — review before shipping.
2034
2246
  *
@@ -2036,14 +2248,17 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
2036
2248
  * [ ] Narrow ${pascal}ResponseSchema to match the real response shape
2037
2249
  * [ ] Adjust ${pascal}PayloadSchema to your actual request parameters
2038
2250
  * [ ] Verify BASE_HEADERS — remove any that aren't load-bearing
2251
+ * [ ] Out-of-tree: \`pnpm add bottleneck zod\` — this file imports both
2252
+ * directly, and a strict node_modules layout (pnpm) won't resolve
2253
+ * them as transitive deps of @enricai/barnacle alone
2039
2254
  */
2040
2255
 
2041
2256
  import Bottleneck from "bottleneck";
2042
2257
  import { z } from "zod/v4";
2043
2258
 
2044
2259
  ${fixtureImport}${caseInsensitiveHeadersImport}${multipartBoolImport}${clientImport}
2045
- import type { BrowserSession } from "../scraper/session";
2046
- import type { SitePlugin, SitePluginContext, SitePluginResult } from "../site-plugin";
2260
+ import type { BrowserSession } from "${ENGINE_PKG}/scraper/session";
2261
+ import type { SitePlugin, SitePluginContext, SitePluginResult } from "${ENGINE_PKG}/site-plugin";
2047
2262
  import { run${pascal}BrowserFlow } from "@/sites/${siteId}/flows/browser-flow";
2048
2263
 
2049
2264
  const BASE_HEADERS: Record<string, string> = {
@@ -2067,13 +2282,14 @@ ${queryConst}${gqlCacheBlock}${fixtureComments}
2067
2282
  * Plugin for ${siteId}. Tries the direct-HTTP hot path first; falls back to
2068
2283
  * Stagehand automatically on schema drift or bot challenge.
2069
2284
  */
2070
- export const ${siteId.replace(/-([a-z])/g, (_, c) => c.toUpperCase())}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
2285
+ export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
2071
2286
  meta: {
2072
2287
  siteId: ${JSON.stringify(siteId)},
2073
2288
  displayName: ${JSON.stringify(pascal.replace(/([A-Z])/g, " $1").trim())},
2074
2289
  bodySchema: ${pascal}PayloadSchema,
2075
2290
  responseSchema: ${pascal}ResponseSchema,
2076
- defaultBaseUrl: ${JSON.stringify(baseUrl)},${hasMultipartStep ? "\n multipart: true," : ""}
2291
+ defaultBaseUrl: ${JSON.stringify(baseUrl)},
2292
+ apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${hasMultipartStep ? "\n multipart: true," : ""}
2077
2293
  },
2078
2294
 
2079
2295
  /** Hot path: direct HTTP — no browser, no LLM tokens. */
@@ -2094,6 +2310,11 @@ ${executeHttpBody}
2094
2310
  return { data: raw as ${pascal}Response };
2095
2311
  },
2096
2312
  };
2313
+
2314
+ // Out-of-tree loader resolves \`m.plugin ?? m.default ?? m\` — this named
2315
+ // alias is what BARNACLE_PLUGINS finds; without it the loader would fall
2316
+ // through to \`m.default\` (the response schema above) and 404 at runtime.
2317
+ export { ${camel}Plugin as plugin };
2097
2318
  `;
2098
2319
  }
2099
2320
  /**
@@ -2163,16 +2384,43 @@ function buildManifestInstruction(instruction, field) {
2163
2384
  `{{ .request.${field} }}` +
2164
2385
  instruction.slice(m.index + m[0].length));
2165
2386
  }
2387
+ /**
2388
+ * The JSON Schema `type` keyword for a sample value. Just the keyword, not a
2389
+ * full schema: the manifest is a scaffold a human narrows, so it needs the real
2390
+ * type a caller must send (`page` is a number, `filters` an array) without
2391
+ * duplicating {@link inferZodSchema}'s recursive shape inference. `null` and
2392
+ * `undefined` fall back to `string`, the safe default for a field a caller fills.
2393
+ */
2394
+ function jsonSchemaTypeOf(value) {
2395
+ if (Array.isArray(value))
2396
+ return "array";
2397
+ if (value === null || value === undefined)
2398
+ return "string";
2399
+ const t = typeof value;
2400
+ if (t === "number")
2401
+ return "number";
2402
+ if (t === "boolean")
2403
+ return "boolean";
2404
+ if (t === "object")
2405
+ return "object";
2406
+ return "string";
2407
+ }
2166
2408
  /**
2167
2409
  * Emits a config-only plugin manifest (`<siteId>.plugin.json`) from the recon
2168
2410
  * flow, as an alternative to the `.ts` trio for browser-only sites. Reuses the
2169
2411
  * SAME `resolveStepPayloadField` splice logic as the browser-flow emitter, so
2170
2412
  * every `{{ .request.<field> }}` reference also lands in the manifest's request
2171
- * schema — the two cannot drift. The direct-HTTP hot path is intentionally
2172
- * omitted; a site that needs it keeps the `.ts` path or wires `spec.httpModule`.
2413
+ * schema — the two cannot drift.
2414
+ *
2415
+ * `recovered` carries the request contract the `.ts` path infers from real
2416
+ * captures — the first POST body's fields plus form-schema discoveries — so
2417
+ * `--emit config` no longer throws that away and emit a request schema built
2418
+ * only from the handful of flow-step splice hints. The direct-HTTP hot path is
2419
+ * still omitted; a site that needs it keeps the `.ts` path or wires
2420
+ * `spec.httpModule` by hand.
2173
2421
  */
2174
2422
  function emitConfigManifest(opts) {
2175
- const { siteId, displayName, baseUrl, flowSteps, vocabulary } = opts;
2423
+ const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields } = opts;
2176
2424
  const payloadFieldNames = new Set();
2177
2425
  const steps = flowSteps.map((step) => {
2178
2426
  const isObj = typeof step !== "string";
@@ -2188,14 +2436,30 @@ function emitConfigManifest(opts) {
2188
2436
  return rewritten;
2189
2437
  return { step: rewritten, optional, upload, submitStep };
2190
2438
  });
2191
- const requestProperties = Object.fromEntries([...payloadFieldNames].sort().map((name) => [name, { type: "string" }]));
2439
+ // The request surface, widest wins: a flow splice, a recovered form field, or
2440
+ // a key from the first POST body all name something a caller controls. Splices
2441
+ // and recovered fields are strings (the browser flow fills them as text); a
2442
+ // body key keeps its captured type so a caller sends `page: 1`, not `"1"`.
2443
+ const requestProperties = {};
2444
+ for (const name of payloadFieldNames)
2445
+ requestProperties[name] = { type: "string" };
2446
+ for (const name of recoveredFields ?? [])
2447
+ requestProperties[name] = { type: "string" };
2448
+ if (inputBody !== null && typeof inputBody === "object" && !Array.isArray(inputBody)) {
2449
+ for (const [name, value] of Object.entries(inputBody)) {
2450
+ requestProperties[name] = { type: jsonSchemaTypeOf(value) };
2451
+ }
2452
+ }
2453
+ const sortedRequestProperties = Object.fromEntries(Object.keys(requestProperties)
2454
+ .sort()
2455
+ .map((name) => [name, requestProperties[name]]));
2192
2456
  const manifest = {
2193
2457
  apiVersion: plugin_manifest_envelope_1.CONFIG_PLUGIN_API_VERSION,
2194
2458
  kind: plugin_manifest_envelope_1.CONFIG_PLUGIN_KIND,
2195
2459
  metadata: { siteId, displayName },
2196
2460
  spec: {
2197
2461
  defaultBaseUrl: baseUrl,
2198
- request: { type: "object", properties: requestProperties },
2462
+ request: { type: "object", properties: sortedRequestProperties },
2199
2463
  response: {
2200
2464
  type: "object",
2201
2465
  description: "TODO: declare the fields this site returns (recon leaves this empty).",
@@ -2270,10 +2534,10 @@ function emitBrowserFlowTs(opts) {
2270
2534
  import type { Stagehand } from "@browserbasehq/stagehand";
2271
2535
  import { z } from "zod/v4";
2272
2536
 
2273
- import { buildAnthropicClient } from "../lib/llm/anthropic-client";
2274
- import { getLogger } from "../lib/logging";
2275
- import { type HealingFlowStep, runHealingFlow, waitForSpaReady } from "../scraper/flow-runner";
2276
- import { guardedExtract } from "../scraper/stagehand-guard";
2537
+ import { buildAnthropicClient } from "${ENGINE_PKG}/lib/llm/anthropic-client";
2538
+ import { getLogger } from "${ENGINE_PKG}/lib/logging";
2539
+ import { type HealingFlowStep, runHealingFlow, waitForSpaReady } from "${ENGINE_PKG}/scraper/flow-runner";
2540
+ import { guardedExtract } from "${ENGINE_PKG}/scraper/stagehand-guard";
2277
2541
  import type { ${pascal}Payload, ${pascal}Response } from "@/sites/${siteId}/contract";
2278
2542
 
2279
2543
  const logger = getLogger({ name: "${siteId}-browser-flow" });
@@ -2332,19 +2596,23 @@ ${flowStepsBlock}
2332
2596
  `;
2333
2597
  return { code, payloadFieldNames };
2334
2598
  }
2599
+ /** Generates the site's index.ts barrel — exported so the out-of-tree e2e
2600
+ * test can drive the emitter directly without spawning the CLI. */
2335
2601
  function emitIndexTs(opts) {
2336
2602
  const { siteId } = opts;
2337
2603
  const camel = siteId.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2338
2604
  return `/**
2339
2605
  * Generated by recon-generate.ts.
2340
- * Register this built-in plugin by adding it to \`BUILTIN_SITE_PLUGINS\` in
2341
- * src/plugins/discover.ts:
2606
+ * Build this package, then point BARNACLE_PLUGINS at the compiled module —
2607
+ * no core edits required:
2608
+ *
2609
+ * BARNACLE_PLUGINS=./dist/sites/${siteId}/index.js pnpm start
2342
2610
  *
2343
- * import { ${camel}Plugin } from "@/sites/${siteId}";
2344
- * BUILTIN_SITE_PLUGINS.push(${camel}Plugin as SitePlugin<unknown, unknown>);
2611
+ * The loader resolves \`m.plugin ?? m.default ?? m\` — the \`plugin\` alias
2612
+ * below is what it finds.
2345
2613
  */
2346
2614
 
2347
- export { ${camel}Plugin } from "@/sites/${siteId}/contract";
2615
+ export { ${camel}Plugin, ${camel}Plugin as plugin } from "@/sites/${siteId}/contract";
2348
2616
  `;
2349
2617
  }
2350
2618
  // ── main ──────────────────────────────────────────────────────────────────────
@@ -2613,6 +2881,7 @@ async function main() {
2613
2881
  s.capture.url.includes("recruitingCEJobApplicationDrafts"));
2614
2882
  if (draftPostStep && !draftPostStep.produces.some((p) => p.name === "draftId")) {
2615
2883
  draftPostStep.produces.push({
2884
+ kind: "body",
2616
2885
  name: "draftId",
2617
2886
  pathExpr: `${draftPostStep.varName}.APPDraftId`,
2618
2887
  path: ["APPDraftId"],
@@ -2621,6 +2890,7 @@ async function main() {
2621
2890
  const attachPostStep = actionSteps.find((s) => s.capture.method === "POST" && s.capture.url.includes("/attachments"));
2622
2891
  if (attachPostStep && !attachPostStep.produces.some((p) => p.name === "attachmentId")) {
2623
2892
  attachPostStep.produces.push({
2893
+ kind: "body",
2624
2894
  name: "attachmentId",
2625
2895
  pathExpr: `${attachPostStep.varName}.Id`,
2626
2896
  path: ["Id"],
@@ -2669,6 +2939,7 @@ async function main() {
2669
2939
  ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride)
2670
2940
  : multiStepBody;
2671
2941
  const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
2942
+ const headerBindings = collectHeaderBindings(actionSteps);
2672
2943
  // For submission flows the final action's response body is the most useful
2673
2944
  // shape inference target (it's the terminal success signal). Fall back to
2674
2945
  // the replay body for single-endpoint sites.
@@ -2684,6 +2955,8 @@ async function main() {
2684
2955
  baseUrl,
2685
2956
  flowSteps,
2686
2957
  vocabulary,
2958
+ inputBody,
2959
+ recoveredFields: [...discoveredFormFields, ...discoveredOptionFields],
2687
2960
  }));
2688
2961
  logger.info(`wrote ${manifestPath}`);
2689
2962
  logger.info(`done — review ${manifestPath}, fill in response/extract schemas, then load via BARNACLE_PLUGINS or BARNACLE_PLUGINS_CONFIG_DIR (no compile step)`);
@@ -2726,6 +2999,7 @@ async function main() {
2726
2999
  discoveredRawOptionFields,
2727
3000
  discoveredAdditionalBodyKeys,
2728
3001
  payloadFieldNames: browserFlow.payloadFieldNames,
3002
+ headerBindings,
2729
3003
  }));
2730
3004
  logger.info(`wrote ${outDir}/contract.ts`);
2731
3005
  (0, node_fs_1.writeFileSync)(`${outDir}/flows/browser-flow.ts`, browserFlow.code);