@enricai/barnacle 1.4.1 → 1.4.2

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,15 +20,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.resolveStepPayloadField = resolveStepPayloadField;
21
21
  exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
22
22
  exports.selectPayloadAction = selectPayloadAction;
23
+ exports.indexStateValues = indexStateValues;
24
+ exports.compileActionSteps = compileActionSteps;
23
25
  exports.loadQuestionPromptKeywords = loadQuestionPromptKeywords;
24
26
  exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
25
27
  exports.emitContractTs = emitContractTs;
26
28
  exports.emitConfigManifest = emitConfigManifest;
27
29
  exports.emitBrowserFlowTs = emitBrowserFlowTs;
30
+ exports.emitIndexTs = emitIndexTs;
28
31
  const node_fs_1 = require("node:fs");
29
32
  const node_path_1 = require("node:path");
30
33
  const errors_1 = require("../lib/errors");
31
34
  const logging_1 = require("../lib/logging");
35
+ const plugin_api_version_1 = require("../plugins/plugin-api-version");
32
36
  const plugin_manifest_envelope_1 = require("../plugins/plugin-manifest-envelope");
33
37
  const load_vocabulary_1 = require("../recon/load-vocabulary");
34
38
  const vocabulary_1 = require("../recon/vocabulary");
@@ -1057,12 +1061,35 @@ const MAX_STATE_VALUE_LENGTH = 256;
1057
1061
  * does not exist". Closed set, literal-string match — never expand to
1058
1062
  * pattern-based detection (would trip the no-regex-on-open-sets rule). */
1059
1063
  const PLACEHOLDER_STATE_VALUES = new Set(["00000000-0000-0000-0000-000000000000"]);
1064
+ /**
1065
+ * Splits a raw `Set-Cookie` response-header string into `name`/`value` pairs.
1066
+ * Captures store `responseHeaders` as a flat `Record<string, string>`
1067
+ * (see recon-shared.ts's `Capture`), so multiple `Set-Cookie` headers from the
1068
+ * same response — if the recon browser's CDP session folds them together —
1069
+ * would already have lost their individual boundaries before reaching here;
1070
+ * this only recovers name/value pairs from whatever single string survives.
1071
+ */
1072
+ function* walkSetCookiePairs(rawSetCookie) {
1073
+ const pair = rawSetCookie.split(";", 1)[0] ?? "";
1074
+ const eq = pair.indexOf("=");
1075
+ if (eq === -1)
1076
+ return;
1077
+ const name = pair.slice(0, eq).trim();
1078
+ const value = pair.slice(eq + 1).trim();
1079
+ if (name && value)
1080
+ yield { name, value };
1081
+ }
1060
1082
  /**
1061
1083
  * Walks every capture's response (including GETs — formHistoryId-style values
1062
1084
  * may originate in a state-load GET, not a POST). Indexes every string leaf
1063
1085
  * whose length is in [MIN, MAX], recording the EARLIEST capture index that
1064
1086
  * produced it. Later occurrences of the same value reuse the earliest origin.
1065
1087
  *
1088
+ * Also indexes response-header/cookie-origin values (e.g. a `Set-Cookie`
1089
+ * auth token) the same way, tagged with `headerOrigin` instead of a body
1090
+ * `path` — this is what lets a stateful API's token-mint response feed a
1091
+ * later call's `Cookie` header via `compileActionSteps`.
1092
+ *
1066
1093
  * The index is intentionally permissive — it doesn't try to shape-match
1067
1094
  * "what looks like a token" because token shapes are an open set across the
1068
1095
  * web. Authoritative filtering happens downstream in `compileActionSteps`,
@@ -1073,6 +1100,8 @@ const PLACEHOLDER_STATE_VALUES = new Set(["00000000-0000-0000-0000-000000000000"
1073
1100
  * the LATER non-placeholder occurrence at the same JSON path becomes the
1074
1101
  * canonical binding instead.
1075
1102
  */
1103
+ /** Exported for unit testing — lets tests exercise the produces[] walk (body
1104
+ * AND header/cookie origins) directly against synthetic Capture sequences. */
1076
1105
  function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set()) {
1077
1106
  const index = new Map();
1078
1107
  // First pass: identify the earliest origin among ACTION captures for each
@@ -1085,10 +1114,32 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
1085
1114
  const haveActionFilter = actionCaptureIndices.size > 0;
1086
1115
  for (let i = 0; i < captures.length; i++) {
1087
1116
  const c = captures[i];
1088
- if (c.responseBody === undefined || c.responseBody === null)
1089
- continue;
1090
1117
  if (haveActionFilter && !actionCaptureIndices.has(i))
1091
1118
  continue;
1119
+ // Headers/cookies are indexed regardless of responseBody presence — a
1120
+ // token-mint call like disneycruise's `authz/private` returns `{}` and
1121
+ // carries its whole payload in `Set-Cookie`.
1122
+ const rawSetCookie = Object.entries(c.responseHeaders).find(([k]) => k.toLowerCase() === "set-cookie")?.[1];
1123
+ if (rawSetCookie !== undefined) {
1124
+ for (const { name, value } of walkSetCookiePairs(rawSetCookie)) {
1125
+ if (value.length < MIN_STATE_VALUE_LENGTH)
1126
+ continue;
1127
+ if (value.length > MAX_STATE_VALUE_LENGTH)
1128
+ continue;
1129
+ if (PLACEHOLDER_STATE_VALUES.has(value))
1130
+ continue;
1131
+ if (!index.has(value)) {
1132
+ index.set(value, {
1133
+ value,
1134
+ originIndex: i,
1135
+ path: [],
1136
+ headerOrigin: { sourceHeader: "set-cookie", cookieName: name },
1137
+ });
1138
+ }
1139
+ }
1140
+ }
1141
+ if (c.responseBody === undefined || c.responseBody === null)
1142
+ continue;
1092
1143
  // For GET captures, only index UUID-shaped strings. GET captures (today,
1093
1144
  // only the form-schema fetch inserted as an action step) surface stable
1094
1145
  // structural identifiers — UUIDs that downstream POSTs need to thread.
@@ -1125,9 +1176,18 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
1125
1176
  function isValidJsIdentifier(s) {
1126
1177
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
1127
1178
  }
1128
- /** Converts a path like ["Auth","Token"] to a JS access expression ".Auth.Token". */
1179
+ /**
1180
+ * Converts a path like ["Auth","Token"] to a JS access expression ".Auth.Token".
1181
+ * Bracket segments (numeric array indices, non-identifier keys) get a trailing
1182
+ * `!` — under `noUncheckedIndexedAccess` an array/index-signature access types
1183
+ * as `T | undefined`, and this accessor is only ever used against a real Zod-
1184
+ * inferred array/object type (payload fields, captured response bodies), never
1185
+ * against the object-literal assertion types `pathToAssertionType` builds (those
1186
+ * use known string-literal keys, which `noUncheckedIndexedAccess` does not
1187
+ * widen). Dot segments stay bare since object property access isn't affected.
1188
+ */
1129
1189
  function pathToAccessor(path) {
1130
- return path.map((p) => (isValidJsIdentifier(p) ? `.${p}` : `[${JSON.stringify(p)}]`)).join("");
1190
+ return path.map((p) => (isValidJsIdentifier(p) ? `.${p}` : `[${JSON.stringify(p)}]!`)).join("");
1131
1191
  }
1132
1192
  /**
1133
1193
  * Builds a nested TypeScript assertion type matching a JSON path. e.g.
@@ -1161,26 +1221,75 @@ function pathToVarName(path) {
1161
1221
  * var name, the state values its response produces (used by downstream steps),
1162
1222
  * and a multipart flag (request body bytes not captured).
1163
1223
  */
1224
+ /** Exported for unit testing — see `indexStateValues`. */
1164
1225
  function compileActionSteps(actions, stateIndex) {
1165
1226
  const usedValues = new Set();
1227
+ // Maps a used state value to the request-header NAME that carries it, for
1228
+ // values whose consuming reference is a request header (not the URL/body).
1229
+ // A header-origin produce needs this as its `targetHeader` — the header the
1230
+ // *next* httpClient call must send the bound value back on. Only the first
1231
+ // consuming header name observed wins; a value used in more than one distinct
1232
+ // header downstream isn't a shape this models (see http-client.ts's `bind`,
1233
+ // which is single-target per binding).
1234
+ const usedValueTargetHeader = new Map();
1166
1235
  // Pre-scan: collect all state values referenced by ANY action's URL/headers/body
1167
1236
  // so we only "produce" the values that are actually consumed downstream.
1168
1237
  for (const { capture } of actions) {
1169
1238
  const haystacks = [capture.url];
1170
- for (const v of Object.values(capture.requestHeaders))
1171
- haystacks.push(v);
1172
1239
  if (capture.requestPostData)
1173
1240
  haystacks.push(capture.requestPostData);
1174
1241
  for (const sv of stateIndex.values()) {
1175
1242
  if (haystacks.some((h) => h.includes(sv.value)))
1176
1243
  usedValues.add(sv.value);
1177
1244
  }
1245
+ for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
1246
+ for (const sv of stateIndex.values()) {
1247
+ if (!headerValue.includes(sv.value))
1248
+ continue;
1249
+ usedValues.add(sv.value);
1250
+ if (!usedValueTargetHeader.has(sv.value)) {
1251
+ usedValueTargetHeader.set(sv.value, headerName);
1252
+ }
1253
+ }
1254
+ }
1178
1255
  }
1179
1256
  let lastHost = null;
1180
1257
  return actions.map(({ capture, index }, i) => {
1181
1258
  const varName = `r${i}`;
1182
1259
  const produces = [];
1183
1260
  const seenNames = new Set();
1261
+ // Header/cookie-origin produces — walked first so a value that appears in
1262
+ // BOTH a Set-Cookie and the JSON body (unlikely, but not ruled out) prefers
1263
+ // the header binding, which is what the runtime actually threads.
1264
+ const rawSetCookie = Object.entries(capture.responseHeaders).find(([k]) => k.toLowerCase() === "set-cookie")?.[1];
1265
+ if (rawSetCookie !== undefined) {
1266
+ for (const { name: cookieName, value } of walkSetCookiePairs(rawSetCookie)) {
1267
+ if (!usedValues.has(value))
1268
+ continue;
1269
+ const sv = stateIndex.get(value);
1270
+ if (!sv || sv.originIndex !== index || !sv.headerOrigin)
1271
+ continue;
1272
+ const targetHeader = usedValueTargetHeader.get(value);
1273
+ if (!targetHeader)
1274
+ continue;
1275
+ let name = `${cookieName.replace(/[^A-Za-z0-9]/g, "")}Cookie`;
1276
+ if (!/^[A-Za-z_$]/.test(name))
1277
+ name = `_${name}`;
1278
+ let suffix = 1;
1279
+ while (seenNames.has(name)) {
1280
+ suffix++;
1281
+ name = `${cookieName.replace(/[^A-Za-z0-9]/g, "")}Cookie${suffix}`;
1282
+ }
1283
+ seenNames.add(name);
1284
+ produces.push({
1285
+ kind: "header",
1286
+ name,
1287
+ sourceHeader: sv.headerOrigin.sourceHeader,
1288
+ cookieName: sv.headerOrigin.cookieName,
1289
+ targetHeader,
1290
+ });
1291
+ }
1292
+ }
1184
1293
  if (capture.responseBody !== undefined && capture.responseBody !== null) {
1185
1294
  for (const { value, path } of walkStringLeaves(capture.responseBody)) {
1186
1295
  if (!usedValues.has(value))
@@ -1196,7 +1305,7 @@ function compileActionSteps(actions, stateIndex) {
1196
1305
  name = `${pathToVarName(path)}${suffix}`;
1197
1306
  }
1198
1307
  seenNames.add(name);
1199
- produces.push({ name, pathExpr: `${varName}${pathToAccessor(path)}`, path });
1308
+ produces.push({ kind: "body", name, pathExpr: `${varName}${pathToAccessor(path)}`, path });
1200
1309
  }
1201
1310
  }
1202
1311
  const ct = Object.entries(capture.requestHeaders).find(([k]) => k.toLowerCase() === "content-type");
@@ -1213,6 +1322,27 @@ function compileActionSteps(actions, stateIndex) {
1213
1322
  return { capture, varName, produces, isMultipart, isCrossDomain };
1214
1323
  });
1215
1324
  }
1325
+ /**
1326
+ * Collects every header/cookie-origin produce across an action sequence, in
1327
+ * step order — this is what `emitContractTs` renders as `createHttpClient`'s
1328
+ * `bind` option so the generated `executeHttp` actually forwards a value like
1329
+ * disneycruise's `Set-Cookie: __pa=<jwt>` mint to the stateful call that 401s
1330
+ * without it. Deduped by `targetHeader`: `HttpResponseBinding` (http-client.ts)
1331
+ * is one binding per target header, so if two steps somehow produced the same
1332
+ * target the earliest wins.
1333
+ */
1334
+ function collectHeaderBindings(actionSteps) {
1335
+ const byTarget = new Map();
1336
+ for (const step of actionSteps) {
1337
+ for (const p of step.produces) {
1338
+ if (p.kind !== "header")
1339
+ continue;
1340
+ if (!byTarget.has(p.targetHeader))
1341
+ byTarget.set(p.targetHeader, p);
1342
+ }
1343
+ }
1344
+ return [...byTarget.values()];
1345
+ }
1216
1346
  /**
1217
1347
  * Replaces occurrences of state values in `template` with `${varName}`
1218
1348
  * interpolations. Returns a JS template-literal string fragment (no backticks).
@@ -1227,6 +1357,12 @@ function interpolateStateValues(template, priorSteps, payloadAccessorByValue = n
1227
1357
  const varNameByValue = new Map();
1228
1358
  for (const step of priorSteps) {
1229
1359
  for (const p of step.produces) {
1360
+ // Header/cookie-origin produces have no body path — their value never
1361
+ // appears as a literal in a URL/body template (http-client's `bind`
1362
+ // forwards it directly as a request header), so there's nothing to
1363
+ // interpolate here.
1364
+ if (p.kind === "header")
1365
+ continue;
1230
1366
  let cursor = step.capture.responseBody;
1231
1367
  for (const segment of p.path) {
1232
1368
  if (cursor !== null &&
@@ -1820,6 +1956,11 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1820
1956
  }
1821
1957
  }
1822
1958
  for (const p of step.produces) {
1959
+ // Header/cookie-origin produces never surface as a JS accessor —
1960
+ // createHttpClient's `bind` option (rendered once, above the steps)
1961
+ // captures and forwards the value internally.
1962
+ if (p.kind === "header")
1963
+ continue;
1823
1964
  if (declaredNames.has(p.name))
1824
1965
  continue;
1825
1966
  if (!referencedNames.has(p.name))
@@ -1845,11 +1986,30 @@ function summariseResponseShape(value) {
1845
1986
  }
1846
1987
  return obj;
1847
1988
  }
1989
+ /**
1990
+ * Renders `headerBindings` as a trailing `, bind: [...]` fragment for
1991
+ * `createHttpClient`'s options object literal — empty string when there are
1992
+ * none, so a plugin with no header/cookie-origin state keeps the exact output
1993
+ * this emitter already produced. Structurally matches `HttpResponseBinding`
1994
+ * (http-client.ts) without importing the type: the object literal typechecks
1995
+ * against `HttpClientOptions.bind` on its own shape.
1996
+ */
1997
+ function bindOptionLiteral(headerBindings) {
1998
+ if (headerBindings.length === 0)
1999
+ return "";
2000
+ const entries = headerBindings
2001
+ .map((b) => {
2002
+ const cookieNameField = b.cookieName !== undefined ? ` cookieName: ${JSON.stringify(b.cookieName)},` : "";
2003
+ return `{ sourceHeader: ${JSON.stringify(b.sourceHeader)},${cookieNameField} targetHeader: ${JSON.stringify(b.targetHeader)} }`;
2004
+ })
2005
+ .join(", ");
2006
+ return `, bind: [${entries}]`;
2007
+ }
1848
2008
  // ── code emitters ─────────────────────────────────────────────────────────────
1849
2009
  /** Generates a complete contract.ts source string for a plugin — exported so
1850
2010
  * unit tests can drive the emitter directly without spawning the CLI. */
1851
2011
  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;
2012
+ 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
2013
  // Multi-step plugins thread responses through many different shapes that a
1854
2014
  // single Zod schema can't cover — use z.unknown() so each per-step access
1855
2015
  // compiles cleanly. Single-endpoint plugins keep the inferred schema.
@@ -2007,7 +2167,7 @@ function getGql(baseUrl: string): GqlFn {
2007
2167
  }
2008
2168
  `
2009
2169
  : `
2010
- const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottleneck: limiter, baseHeaders: BASE_HEADERS });
2170
+ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)} });
2011
2171
  `;
2012
2172
  const executeHttpBody = multiStepBody
2013
2173
  ? multiStepBody
@@ -2029,6 +2189,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
2029
2189
  const queryChecklistLine = gql
2030
2190
  ? `\n * [ ] Trim UI-only fields from ${pascal.toUpperCase()}_QUERY (keep only fields you need)`
2031
2191
  : "";
2192
+ const camel = siteId.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2032
2193
  return `/**
2033
2194
  * Generated by recon-generate.ts — review before shipping.
2034
2195
  *
@@ -2036,6 +2197,9 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
2036
2197
  * [ ] Narrow ${pascal}ResponseSchema to match the real response shape
2037
2198
  * [ ] Adjust ${pascal}PayloadSchema to your actual request parameters
2038
2199
  * [ ] Verify BASE_HEADERS — remove any that aren't load-bearing
2200
+ * [ ] Out-of-tree: \`pnpm add bottleneck zod\` — this file imports both
2201
+ * directly, and a strict node_modules layout (pnpm) won't resolve
2202
+ * them as transitive deps of @enricai/barnacle alone
2039
2203
  */
2040
2204
 
2041
2205
  import Bottleneck from "bottleneck";
@@ -2067,13 +2231,14 @@ ${queryConst}${gqlCacheBlock}${fixtureComments}
2067
2231
  * Plugin for ${siteId}. Tries the direct-HTTP hot path first; falls back to
2068
2232
  * Stagehand automatically on schema drift or bot challenge.
2069
2233
  */
2070
- export const ${siteId.replace(/-([a-z])/g, (_, c) => c.toUpperCase())}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
2234
+ export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
2071
2235
  meta: {
2072
2236
  siteId: ${JSON.stringify(siteId)},
2073
2237
  displayName: ${JSON.stringify(pascal.replace(/([A-Z])/g, " $1").trim())},
2074
2238
  bodySchema: ${pascal}PayloadSchema,
2075
2239
  responseSchema: ${pascal}ResponseSchema,
2076
- defaultBaseUrl: ${JSON.stringify(baseUrl)},${hasMultipartStep ? "\n multipart: true," : ""}
2240
+ defaultBaseUrl: ${JSON.stringify(baseUrl)},
2241
+ apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${hasMultipartStep ? "\n multipart: true," : ""}
2077
2242
  },
2078
2243
 
2079
2244
  /** Hot path: direct HTTP — no browser, no LLM tokens. */
@@ -2094,6 +2259,11 @@ ${executeHttpBody}
2094
2259
  return { data: raw as ${pascal}Response };
2095
2260
  },
2096
2261
  };
2262
+
2263
+ // Out-of-tree loader resolves \`m.plugin ?? m.default ?? m\` — this named
2264
+ // alias is what BARNACLE_PLUGINS finds; without it the loader would fall
2265
+ // through to \`m.default\` (the response schema above) and 404 at runtime.
2266
+ export { ${camel}Plugin as plugin };
2097
2267
  `;
2098
2268
  }
2099
2269
  /**
@@ -2332,19 +2502,23 @@ ${flowStepsBlock}
2332
2502
  `;
2333
2503
  return { code, payloadFieldNames };
2334
2504
  }
2505
+ /** Generates the site's index.ts barrel — exported so the out-of-tree e2e
2506
+ * test can drive the emitter directly without spawning the CLI. */
2335
2507
  function emitIndexTs(opts) {
2336
2508
  const { siteId } = opts;
2337
2509
  const camel = siteId.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2338
2510
  return `/**
2339
2511
  * Generated by recon-generate.ts.
2340
- * Register this built-in plugin by adding it to \`BUILTIN_SITE_PLUGINS\` in
2341
- * src/plugins/discover.ts:
2512
+ * Build this package, then point BARNACLE_PLUGINS at the compiled module —
2513
+ * no core edits required:
2514
+ *
2515
+ * BARNACLE_PLUGINS=./dist/sites/${siteId}/index.js pnpm start
2342
2516
  *
2343
- * import { ${camel}Plugin } from "@/sites/${siteId}";
2344
- * BUILTIN_SITE_PLUGINS.push(${camel}Plugin as SitePlugin<unknown, unknown>);
2517
+ * The loader resolves \`m.plugin ?? m.default ?? m\` — the \`plugin\` alias
2518
+ * below is what it finds.
2345
2519
  */
2346
2520
 
2347
- export { ${camel}Plugin } from "@/sites/${siteId}/contract";
2521
+ export { ${camel}Plugin, ${camel}Plugin as plugin } from "@/sites/${siteId}/contract";
2348
2522
  `;
2349
2523
  }
2350
2524
  // ── main ──────────────────────────────────────────────────────────────────────
@@ -2613,6 +2787,7 @@ async function main() {
2613
2787
  s.capture.url.includes("recruitingCEJobApplicationDrafts"));
2614
2788
  if (draftPostStep && !draftPostStep.produces.some((p) => p.name === "draftId")) {
2615
2789
  draftPostStep.produces.push({
2790
+ kind: "body",
2616
2791
  name: "draftId",
2617
2792
  pathExpr: `${draftPostStep.varName}.APPDraftId`,
2618
2793
  path: ["APPDraftId"],
@@ -2621,6 +2796,7 @@ async function main() {
2621
2796
  const attachPostStep = actionSteps.find((s) => s.capture.method === "POST" && s.capture.url.includes("/attachments"));
2622
2797
  if (attachPostStep && !attachPostStep.produces.some((p) => p.name === "attachmentId")) {
2623
2798
  attachPostStep.produces.push({
2799
+ kind: "body",
2624
2800
  name: "attachmentId",
2625
2801
  pathExpr: `${attachPostStep.varName}.Id`,
2626
2802
  path: ["Id"],
@@ -2669,6 +2845,7 @@ async function main() {
2669
2845
  ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride)
2670
2846
  : multiStepBody;
2671
2847
  const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
2848
+ const headerBindings = collectHeaderBindings(actionSteps);
2672
2849
  // For submission flows the final action's response body is the most useful
2673
2850
  // shape inference target (it's the terminal success signal). Fall back to
2674
2851
  // the replay body for single-endpoint sites.
@@ -2726,6 +2903,7 @@ async function main() {
2726
2903
  discoveredRawOptionFields,
2727
2904
  discoveredAdditionalBodyKeys,
2728
2905
  payloadFieldNames: browserFlow.payloadFieldNames,
2906
+ headerBindings,
2729
2907
  }));
2730
2908
  logger.info(`wrote ${outDir}/contract.ts`);
2731
2909
  (0, node_fs_1.writeFileSync)(`${outDir}/flows/browser-flow.ts`, browserFlow.code);