@enricai/barnacle 1.12.9 → 1.12.11

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.
@@ -75,6 +75,80 @@ const logger = (0, logging_1.getScriptLogger)("recon-generate");
75
75
  */
76
76
  const ENGINE_PKG = "@enricai/barnacle";
77
77
  // ── helpers ──────────────────────────────────────────────────────────────────
78
+ /**
79
+ * A reserved recon env token, e.g. `${RECON_EMAIL}` or `${RECON_PHONE}` — the
80
+ * `RECON_` prefix is what marks a `${UPPER_SNAKE}` token as a recon-owned
81
+ * splice site rather than an unrelated caller-authored env reference.
82
+ */
83
+ const RESERVED_ENV_TOKEN = /\$\{RECON_[A-Z0-9_]*\}/;
84
+ /**
85
+ * The reserved `${RECON_PASSWORD}` token. Unlike every other `RESERVED_ENV_TOKEN`
86
+ * (RECON_EMAIL, RECON_PHONE, ...), which name a piece of the caller's real
87
+ * applicant identity and so splice to a `payload.<field>` accessor, this one
88
+ * names a credential the recon capture needed to authenticate but that has no
89
+ * caller-supplied counterpart on the applicant payload — there is no "Password"
90
+ * field to route it through. It gets its own reserved-tooling handling ahead of
91
+ * (never through) vocabulary/payload-field resolution.
92
+ */
93
+ const RECON_PASSWORD_TOKEN = `$${"{RECON_PASSWORD}"}`;
94
+ /**
95
+ * Masks the apostrophe in a possessive `'s` (e.g. "the candidate's name") with
96
+ * a non-quote placeholder of the same length, so a naive `'...'` quote scan
97
+ * doesn't mistake the possessive apostrophe for an opening quote delimiter.
98
+ * Length-preserving so callers that need positions in the ORIGINAL instruction
99
+ * can reuse the indices matched against the masked string unchanged.
100
+ */
101
+ function maskPossessiveApostrophes(instruction) {
102
+ return instruction.replace(/(\w)'(s\b)/g, "$1 $2");
103
+ }
104
+ function findQuoteSpans(instruction) {
105
+ const masked = maskPossessiveApostrophes(instruction);
106
+ return [...masked.matchAll(/'([^']*)'/g)].map((m) => ({
107
+ index: m.index,
108
+ length: m[0].length,
109
+ value: instruction.slice(m.index + 1, m.index + m[0].length - 1),
110
+ }));
111
+ }
112
+ /**
113
+ * Picks which quoted span in an instruction is the persona VALUE, per the
114
+ * grammar recon-flow steps use: a `Select`/`Choose`/`Pick` step names the
115
+ * ANSWER first, then the question, so the value is the FIRST quoted span; a
116
+ * `Fill`/`Enter`/`Type` step names the field label first and the value last,
117
+ * so it is the LAST quoted span.
118
+ */
119
+ function pickValueSpan(instruction, spans) {
120
+ return /^\s*(select|choose|pick)\b/i.test(instruction) ? spans[0] : spans[spans.length - 1];
121
+ }
122
+ /**
123
+ * Locates the splice site in a flow-step instruction as `{ before, matched,
124
+ * after }` slices of the ORIGINAL instruction — a reserved `${RECON_*}` env
125
+ * token when present (preferred, since it names its own site unambiguously),
126
+ * otherwise the quoted VALUE span per {@link pickValueSpan}'s verb-class rule.
127
+ * This is the position-aware counterpart to {@link extractStepPersonaValue}:
128
+ * that function only needs the extracted string, this one needs the actual
129
+ * before/after slices so a template literal can be rebuilt around the site.
130
+ *
131
+ * @returns null when the instruction carries no spliceable site
132
+ */
133
+ function locateSpliceSite(instruction) {
134
+ const envToken = RESERVED_ENV_TOKEN.exec(instruction);
135
+ if (envToken) {
136
+ return {
137
+ before: instruction.slice(0, envToken.index),
138
+ matched: envToken[0],
139
+ after: instruction.slice(envToken.index + envToken[0].length),
140
+ };
141
+ }
142
+ const spans = findQuoteSpans(instruction);
143
+ if (spans.length === 0)
144
+ return null;
145
+ const span = pickValueSpan(instruction, spans);
146
+ return {
147
+ before: instruction.slice(0, span.index),
148
+ matched: instruction.slice(span.index, span.index + span.length),
149
+ after: instruction.slice(span.index + span.length),
150
+ };
151
+ }
78
152
  function toPascalCase(siteId) {
79
153
  return siteId
80
154
  .split(/[-_]/)
@@ -102,9 +176,9 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
102
176
  return null;
103
177
  if (explicit)
104
178
  return explicit;
105
- // A quoted literal or ${RECON_EMAIL} IS the recon constant this step would
106
- // replace, so it is spliceable on its own.
107
- const hasQuotedConstant = /'[^']*'/.test(instruction) || /\$\{RECON_EMAIL\}/.test(instruction);
179
+ // A quoted literal or a reserved ${RECON_*} env token IS the recon constant
180
+ // this step would replace, so it is spliceable on its own.
181
+ const hasQuotedConstant = /'[^']*'/.test(instruction) || RESERVED_ENV_TOKEN.test(instruction);
108
182
  // A dropdown step carries no constant to replace, so a label match alone can't
109
183
  // tell "select the test candidate's state" (the caller's data) from "select the
110
184
  // neighborhood from the Country dropdown" (a facet that merely says Country).
@@ -159,14 +233,10 @@ function extractStepPersonaValue(instruction, env) {
159
233
  if (resolved)
160
234
  return resolved;
161
235
  }
162
- // Neutralize the possessive apostrophe so it isn't read as a quote delimiter.
163
- const cleaned = instruction.replace(/(\w)'s\b/g, "$1s");
164
- const quotes = [...cleaned.matchAll(/'([^']*)'/g)].map((m) => m[1]);
165
- if (quotes.length === 0)
236
+ const spans = findQuoteSpans(instruction);
237
+ if (spans.length === 0)
166
238
  return null;
167
- const value = /^\s*(select|choose|pick)\b/i.test(instruction)
168
- ? quotes[0]
169
- : quotes[quotes.length - 1];
239
+ const value = pickValueSpan(instruction, spans).value;
170
240
  return value.length > 0 ? value : null;
171
241
  }
172
242
  /**
@@ -3308,37 +3378,82 @@ function emitContractTs(opts) {
3308
3378
  // longer drives the public schema. A missing inputBody means this is a
3309
3379
  // non-submission (query-type) flow, which keeps its own contract untouched.
3310
3380
  const basePayloadSchemaExpr = inputBody
3311
- ? `ApplicantContactSchema.extend({\n Email: z.email(),\n ClickUrl: z.string().min(1),\n Answers: multipartJsonObject(z.record(z.string(), z.unknown())),\n})`
3381
+ ? `ApplicantContactSchema`
3312
3382
  : `z.object({\n query: z.string().min(1),\n})`;
3383
+ // Every field source below (the base extend's own keys, form-schema
3384
+ // discovery, browser-flow splicing, option/raw-option enums, additional
3385
+ // body keys, and structured keys) is merged into a SINGLE `.extend({...})`
3386
+ // object literal, keyed by field name, rather than each becoming its own
3387
+ // chained `.extend()` call. A name that recurs across sources collapses to
3388
+ // one declaration — the later source in this list wins, mirroring the
3389
+ // override semantics a chain of `.extend()` calls used to have (each
3390
+ // subsequent `.extend` replaced an earlier field of the same name).
3391
+ const extendFields = new Map();
3392
+ const addExtendField = (name, line) => {
3393
+ extendFields.set(name, line);
3394
+ };
3395
+ // The base extend's own keys — submission flows only.
3396
+ if (inputBody) {
3397
+ addExtendField("Email", " Email: z.email(),");
3398
+ addExtendField("ClickUrl", " ClickUrl: z.string().min(1),");
3399
+ addExtendField("Answers", " Answers: multipartJsonObject(z.record(z.string(), z.unknown())),");
3400
+ }
3401
+ // ApplicantContactSchema's own merged identity/address/resume field names
3402
+ // (see src/lib/application-identity.ts, application-address.ts,
3403
+ // application-resume.ts, applicant-payload.ts) — reserved so no discovered/
3404
+ // spliced source can redeclare (and silently shadow) a field the base
3405
+ // ApplicantContactSchema already supplies. Only relevant for submission
3406
+ // flows, where basePayloadSchemaExpr actually is ApplicantContactSchema.
3407
+ const applicantContactFieldNames = new Set([
3408
+ "FirstName",
3409
+ "LastName",
3410
+ "Phone",
3411
+ "AddressLine",
3412
+ "City",
3413
+ "State",
3414
+ "PostalCode",
3415
+ "Country",
3416
+ "County",
3417
+ "Resume",
3418
+ "ResumeContentType",
3419
+ "ResumeFilename",
3420
+ "ResumeBase64",
3421
+ ]);
3422
+ const isReservedByApplicantContactSchema = (name) => Boolean(inputBody) && applicantContactFieldNames.has(name);
3423
+ // Multi-step flows that include a multipart upload need the binary asset
3424
+ // on the payload. A query-type flow (no ApplicantContactSchema base) still
3425
+ // needs these fields spelled out explicitly.
3426
+ if (hasMultipartStep && !inputBody) {
3427
+ addExtendField("Resume", " Resume: z.instanceof(Buffer),");
3428
+ addExtendField("ResumeContentType", " ResumeContentType: z.string(),");
3429
+ addExtendField("ResumeFilename", " ResumeFilename: z.string(),");
3430
+ }
3313
3431
  // Form-schema-discovered fields (e.g. AddressLine1, UserSsn, Reference1FirstName)
3314
3432
  // are added to the payload as required strings. Site-agnostic: the set is
3315
3433
  // populated by applyFormSchemaSubstitutions when the recon includes a
3316
3434
  // detectable form schema; empty for sites without one.
3317
- const formFieldsExtension = discoveredFormFields && discoveredFormFields.size > 0
3318
- ? `.extend({\n${[...discoveredFormFields]
3319
- .sort()
3320
- .map((name) => ` ${name}: z.string(),`)
3321
- .join("\n")}\n})`
3322
- : "";
3435
+ if (discoveredFormFields) {
3436
+ for (const name of [...discoveredFormFields].sort()) {
3437
+ if (isReservedByApplicantContactSchema(name))
3438
+ continue;
3439
+ addExtendField(name, ` ${name}: z.string(),`);
3440
+ }
3441
+ }
3323
3442
  // Candidate-PII fields the browser flow splices as `payload.<field>`. Emitted
3324
3443
  // as required strings (z.email() for Email per the repo's z.string().email()→
3325
3444
  // z.email() migration) so those references typecheck in the generated flow.
3326
- // Skip any field the form-schema pass already added to avoid a duplicate
3327
- // `.extend` key.
3328
- const splicedFieldNames = payloadFieldNames
3329
- ? [...payloadFieldNames].filter((name) => !discoveredFormFields?.has(name)).sort()
3330
- : [];
3331
- const splicedFieldsExtension = splicedFieldNames.length > 0
3332
- ? `.extend({\n${splicedFieldNames
3333
- .map((name) => ` ${name}: ${name === "Email" ? "z.email()" : "z.string()"},`)
3334
- .join("\n")}\n})`
3335
- : "";
3445
+ if (payloadFieldNames) {
3446
+ for (const name of [...payloadFieldNames].sort()) {
3447
+ if (isReservedByApplicantContactSchema(name))
3448
+ continue;
3449
+ addExtendField(name, ` ${name}: ${name === "Email" ? "z.email()" : "z.string()"},`);
3450
+ }
3451
+ }
3336
3452
  // Build per-field OPT_<Name> constant declarations + payload-schema enum
3337
3453
  // entries from the form schema's options. Only fields whose option-id
3338
3454
  // slots were actually rewritten in the body (i.e. that appear in
3339
3455
  // discoveredOptionFields) get emitted; the rest leave their schema entries
3340
- // unused. Computed BEFORE payloadSchemaExpr so the extension string is
3341
- // available for the final schema concat.
3456
+ // unused.
3342
3457
  const emittedOptionMappings = [];
3343
3458
  if (fieldOptionsMap && discoveredOptionFields && discoveredOptionFields.size > 0) {
3344
3459
  for (const mapping of fieldOptionsMap.values()) {
@@ -3356,11 +3471,11 @@ function emitContractTs(opts) {
3356
3471
  return `\nconst OPT_${mapping.semanticName} = {\n${entries}\n} as const;\n`;
3357
3472
  })
3358
3473
  .join("");
3359
- const optionSchemaExtension = emittedOptionMappings.length > 0
3360
- ? `.extend({\n${emittedOptionMappings
3361
- .map((m) => ` ${m.semanticName}: z.enum([${m.options.map((o) => JSON.stringify(o.value)).join(", ")}]),`)
3362
- .join("\n")}\n})`
3363
- : "";
3474
+ for (const mapping of emittedOptionMappings) {
3475
+ if (isReservedByApplicantContactSchema(mapping.semanticName))
3476
+ continue;
3477
+ addExtendField(mapping.semanticName, ` ${mapping.semanticName}: z.enum([${mapping.options.map((o) => JSON.stringify(o.value)).join(", ")}]),`);
3478
+ }
3364
3479
  // Phase E raw-option payload fields: options whose label strings are empty in
3365
3480
  // the schema — no semantic enum is possible, so the caller supplies the
3366
3481
  // option-id UUID directly. The recon-observed UUID is documented in a TSDoc
@@ -3368,11 +3483,11 @@ function emitContractTs(opts) {
3368
3483
  const sortedRawOptionEntries = discoveredRawOptionFields
3369
3484
  ? [...discoveredRawOptionFields.entries()].sort(([a], [b]) => a.localeCompare(b))
3370
3485
  : [];
3371
- const rawOptionSchemaExtension = sortedRawOptionEntries.length > 0
3372
- ? `.extend({\n${sortedRawOptionEntries
3373
- .map(([name, reconUuid]) => ` /** Recon-observed: ${reconUuid}. Caller supplies the option-id UUID for this field. */\n ${name}: z.string(),`)
3374
- .join("\n")}\n})`
3375
- : "";
3486
+ for (const [name, reconUuid] of sortedRawOptionEntries) {
3487
+ if (isReservedByApplicantContactSchema(name))
3488
+ continue;
3489
+ addExtendField(name, ` /** Recon-observed: ${reconUuid}. Caller supplies the option-id UUID for this field. */\n ${name}: z.string(),`);
3490
+ }
3376
3491
  // A non-scalar (Mechanism B) field forces multipart wire encoding just like
3377
3492
  // an upload step does: the multipart body encodes arrays/objects as
3378
3493
  // JSON-stringified strings, so those fields need the same
@@ -3383,25 +3498,23 @@ function emitContractTs(opts) {
3383
3498
  const sortedAdditionalKeys = discoveredAdditionalBodyKeys
3384
3499
  ? [...discoveredAdditionalBodyKeys.entries()].sort(([a], [b]) => a.localeCompare(b))
3385
3500
  : [];
3386
- const additionalBodyKeysExtension = sortedAdditionalKeys.length > 0
3387
- ? `.extend({\n${sortedAdditionalKeys
3388
- .map(([name, kind]) => {
3389
- // Use multipartBoolean() for booleans when multipart is in play, so
3390
- // multipart string-encoded "true"/"false" round-trip to native
3391
- // booleans (matches the inputBody boolean handling for parity).
3392
- const zod = kind === "string"
3393
- ? "z.string()"
3394
- : kind === "number"
3395
- ? payloadNeedsMultipart
3396
- ? "z.coerce.number()"
3397
- : "z.number()"
3398
- : payloadNeedsMultipart
3399
- ? "multipartBoolean()"
3400
- : "z.boolean()";
3401
- return ` ${name}: ${zod},`;
3402
- })
3403
- .join("\n")}\n})`
3404
- : "";
3501
+ for (const [name, kind] of sortedAdditionalKeys) {
3502
+ if (isReservedByApplicantContactSchema(name))
3503
+ continue;
3504
+ // Use multipartBoolean() for booleans when multipart is in play, so
3505
+ // multipart string-encoded "true"/"false" round-trip to native booleans
3506
+ // (matches the inputBody boolean handling for parity).
3507
+ const zod = kind === "string"
3508
+ ? "z.string()"
3509
+ : kind === "number"
3510
+ ? payloadNeedsMultipart
3511
+ ? "z.coerce.number()"
3512
+ : "z.number()"
3513
+ : payloadNeedsMultipart
3514
+ ? "multipartBoolean()"
3515
+ : "z.boolean()";
3516
+ addExtendField(name, ` ${name}: ${zod},`);
3517
+ }
3405
3518
  // Mechanism B: nested caller structures become payload fields carrying their
3406
3519
  // inferred schema. Emitted as an object body so multi-line z.array(z.object(
3407
3520
  // …)) expressions indent cleanly; a leading TSDoc flags eventData's opaque
@@ -3409,15 +3522,13 @@ function emitContractTs(opts) {
3409
3522
  const sortedStructuredEntries = discoveredStructuredKeys
3410
3523
  ? [...discoveredStructuredKeys.entries()].sort(([a], [b]) => a.localeCompare(b))
3411
3524
  : [];
3412
- const structuredKeysExtension = sortedStructuredEntries.length > 0
3413
- ? `.extend({\n${sortedStructuredEntries
3414
- .map(([name, schema]) => {
3415
- const key = isValidJsIdentifier(name) ? name : JSON.stringify(name);
3416
- const value = payloadNeedsMultipart ? `multipartJsonObject(${schema})` : schema;
3417
- return ` ${key}: ${value},`;
3418
- })
3419
- .join("\n")}\n})`
3420
- : "";
3525
+ for (const [name, schema] of sortedStructuredEntries) {
3526
+ if (isReservedByApplicantContactSchema(name))
3527
+ continue;
3528
+ const key = isValidJsIdentifier(name) ? name : JSON.stringify(name);
3529
+ const value = payloadNeedsMultipart ? `multipartJsonObject(${schema})` : schema;
3530
+ addExtendField(name, ` ${key}: ${value},`);
3531
+ }
3421
3532
  // The structural walk over the captured request body that used to BE the
3422
3533
  // public payload schema (see basePayloadSchemaExpr above) is still the
3423
3534
  // right starting point for the plugin author's internal builder — it's
@@ -3430,18 +3541,18 @@ function emitContractTs(opts) {
3430
3541
  const internalRequestReferenceExpr = inputBody
3431
3542
  ? inferZodSchema(inputBody, 0, "", { multipartCoerce: hasMultipartStep })
3432
3543
  : null;
3433
- // optionSchemaExtension is appended LAST so option enums show up at the
3434
- // end of the payload typethe section ordering (base, multipart fields,
3435
- // form-schema fields, option enums, raw-option fields) matches the body
3436
- // emit order and keeps the generated payload type readable.
3437
- const resumeFieldsExtension = hasMultipartStep && !inputBody
3438
- ? `.extend({\n Resume: z.instanceof(Buffer),\n ResumeContentType: z.string(),\n ResumeFilename: z.string(),\n})`
3439
- : "";
3440
- const payloadSchemaExpr = `${basePayloadSchemaExpr}${resumeFieldsExtension}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${structuredKeysExtension}`;
3441
- // basePayloadSchemaExpr always wraps Answers in multipartJsonObject() for
3442
- // submission flows (inputBody set); multipartBoolean() and the
3443
- // structuredKeysExtension wrapping are needed whenever payloadNeedsMultipart
3444
- // is true (an upload step OR a non-scalar discoveredStructuredKeys field).
3544
+ // All field sources above are merged into a SINGLE `.extend({...})` object
3545
+ // literal, keyed by field namea name that recurs across sources (or
3546
+ // that collides with the base extend's own Email/ClickUrl/Answers) collapses
3547
+ // to its last-declared line, rather than becoming a second, dupe-prone
3548
+ // `.extend()` call chained onto the schema.
3549
+ const mergedExtension = extendFields.size > 0 ? `.extend({\n${[...extendFields.values()].join("\n")}\n})` : "";
3550
+ const payloadSchemaExpr = `${basePayloadSchemaExpr}${mergedExtension}`;
3551
+ // basePayloadSchemaExpr's own Answers field always wraps in
3552
+ // multipartJsonObject() for submission flows (inputBody set);
3553
+ // multipartBoolean() and the structured-keys wrapping above are needed
3554
+ // whenever payloadNeedsMultipart is true (an upload step OR a non-scalar
3555
+ // discoveredStructuredKeys field).
3445
3556
  // Named imports from the same module are combined into one import statement.
3446
3557
  const zodMultipartNamedImports = [
3447
3558
  ...(payloadNeedsMultipart ? ["multipartBoolean"] : []),
@@ -3680,59 +3791,50 @@ function escapeForTemplateLiteral(segment) {
3680
3791
  * Build the emitted instruction expression for one step: a plain double-quoted
3681
3792
  * literal when nothing splices, or a backtick template literal with the recon
3682
3793
  * constant replaced by `${payload.<field>}` when the resolver picks a field.
3683
- * The first `${RECON_EMAIL}` token (preferred) or the first single-quoted
3684
- * literal in the instruction is the splice site.
3794
+ * The splice site is located by {@link locateSpliceSite} — a reserved
3795
+ * `${RECON_*}` env token when present, otherwise the quoted VALUE span (never
3796
+ * a selector's or label's quoted span), matching {@link extractStepPersonaValue}'s
3797
+ * own choice so the browser-flow and HTTP-body emitters never disagree on
3798
+ * which quoted span is the persona value.
3685
3799
  */
3686
3800
  function buildStepInstructionExpr(instruction, field) {
3687
3801
  if (field === null)
3688
3802
  return JSON.stringify(instruction);
3689
- // Concatenated so Biome's noTemplateCurlyInString doesn't flag the literal
3690
- // env-var token — it must stay `${RECON_EMAIL}` to match recon's flow files.
3691
- const emailToken = `$${"{RECON_EMAIL}"}`;
3692
- const emailIdx = instruction.indexOf(emailToken);
3693
- const [before, matched, after] = emailIdx >= 0
3694
- ? [
3695
- instruction.slice(0, emailIdx),
3696
- emailToken,
3697
- instruction.slice(emailIdx + emailToken.length),
3698
- ]
3699
- : (() => {
3700
- const m = /'[^']*'/.exec(instruction);
3701
- if (m === null)
3702
- return [instruction, "", ""];
3703
- return [
3704
- instruction.slice(0, m.index),
3705
- m[0],
3706
- instruction.slice(m.index + m[0].length),
3707
- ];
3708
- })();
3709
- if (matched === "")
3803
+ const site = locateSpliceSite(instruction);
3804
+ if (site === null)
3710
3805
  return JSON.stringify(instruction);
3711
- return `\`${escapeForTemplateLiteral(before)}\${payload.${field}}${escapeForTemplateLiteral(after)}\``;
3806
+ return `\`${escapeForTemplateLiteral(site.before)}\${payload.${field}}${escapeForTemplateLiteral(site.after)}\``;
3712
3807
  }
3713
3808
  /**
3714
- * Rewrites one step instruction into the config-manifest templating form:
3715
- * the recon splice site (a `${RECON_EMAIL}` token or the first single-quoted
3716
- * literal) becomes `{{ .request.<field> }}`. Unlike {@link buildStepInstructionExpr}
3717
- * this yields a plain manifest string, not a TS expression — the runtime
3718
- * config-plugin resolver, not the code generator, performs the splice.
3809
+ * Build the emitted instruction expression for a step whose splice site is the
3810
+ * reserved `${RECON_PASSWORD}` token: a backtick template literal with the
3811
+ * token replaced by `${throwawayPassword}`, the per-run credential minted by
3812
+ * {@link generateThrowawayPassword} never the recon capture's literal
3813
+ * password, and never routed through `payload.<field>` since no caller-
3814
+ * supplied Password field exists on the applicant payload.
3815
+ */
3816
+ function buildPasswordInstructionExpr(instruction) {
3817
+ const site = locateSpliceSite(instruction);
3818
+ if (site === null)
3819
+ return JSON.stringify(instruction);
3820
+ return `\`${escapeForTemplateLiteral(site.before)}\${throwawayPassword}${escapeForTemplateLiteral(site.after)}\``;
3821
+ }
3822
+ /**
3823
+ * Rewrites one step instruction into the config-manifest templating form: the
3824
+ * splice site located by {@link locateSpliceSite} becomes `{{ .request.<field> }}`.
3825
+ * Unlike {@link buildStepInstructionExpr} this yields a plain manifest string,
3826
+ * not a TS expression — the runtime config-plugin resolver, not the code
3827
+ * generator, performs the splice. Reuses {@link locateSpliceSite} so this
3828
+ * emitter never lands the splice on a selector's or label's quoted span, the
3829
+ * same guarantee {@link buildStepInstructionExpr} makes.
3719
3830
  */
3720
3831
  function buildManifestInstruction(instruction, field) {
3721
3832
  if (field === null)
3722
3833
  return instruction;
3723
- const emailToken = `$${"{RECON_EMAIL}"}`;
3724
- const emailIdx = instruction.indexOf(emailToken);
3725
- if (emailIdx >= 0) {
3726
- return (instruction.slice(0, emailIdx) +
3727
- `{{ .request.${field} }}` +
3728
- instruction.slice(emailIdx + emailToken.length));
3729
- }
3730
- const m = /'[^']*'/.exec(instruction);
3731
- if (m === null)
3834
+ const site = locateSpliceSite(instruction);
3835
+ if (site === null)
3732
3836
  return instruction;
3733
- return (instruction.slice(0, m.index) +
3734
- `{{ .request.${field} }}` +
3735
- instruction.slice(m.index + m[0].length));
3837
+ return `${site.before}{{ .request.${field} }}${site.after}`;
3736
3838
  }
3737
3839
  /**
3738
3840
  * The JSON Schema `type` keyword for a sample value. Just the keyword, not a
@@ -3779,6 +3881,21 @@ function emitConfigManifest(opts) {
3779
3881
  const steps = flowSteps.map((step) => {
3780
3882
  const isObj = typeof step !== "string";
3781
3883
  const instruction = isObj ? step.step : step;
3884
+ // A config-only manifest has no compiled code to mint a throwaway credential
3885
+ // at runtime (unlike emitBrowserFlowTs's generateThrowawayPassword() splice),
3886
+ // so ${RECON_PASSWORD} is routed to an explicit "Password" request field
3887
+ // instead — the operator supplies it at call time. Either way the literal
3888
+ // token must never survive into the manifest.
3889
+ if (instruction.includes(RECON_PASSWORD_TOKEN)) {
3890
+ payloadFieldNames.add("Password");
3891
+ const rewritten = buildManifestInstruction(instruction, "Password");
3892
+ const optional = isObj ? step.optional === true : false;
3893
+ const upload = isObj ? step.upload === true : false;
3894
+ const submitStep = isObj ? step.submitStep === true : false;
3895
+ if (!optional && !upload && !submitStep)
3896
+ return rewritten;
3897
+ return { step: rewritten, optional, upload, submitStep };
3898
+ }
3782
3899
  const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary);
3783
3900
  if (field !== null)
3784
3901
  payloadFieldNames.add(field);
@@ -3843,9 +3960,23 @@ function emitBrowserFlowTs(opts) {
3843
3960
  const { siteId, pascal, flowSteps, isSubmissionFlow, hasMultipartStep = false, vocabulary, frameSelector, } = opts;
3844
3961
  const payloadFieldNames = new Set();
3845
3962
  const hasUploadStep = flowSteps.some((s) => typeof s !== "string" && s.upload === true);
3963
+ let usesThrowawayPassword = false;
3846
3964
  const stepLiterals = flowSteps.map((step) => {
3847
3965
  const isObj = typeof step !== "string";
3848
3966
  const instruction = isObj ? step.step : step;
3967
+ // ${RECON_PASSWORD} is reserved-tooling, not a domain-vocabulary concern —
3968
+ // it names a credential the recon capture needed to authenticate, not a
3969
+ // piece of the caller's applicant identity, so it never reaches
3970
+ // resolveStepPayloadField/vocabulary and never routes through
3971
+ // payload.<field>. It gets a generated throwaway credential instead.
3972
+ if (instruction.includes(RECON_PASSWORD_TOKEN)) {
3973
+ usesThrowawayPassword = true;
3974
+ const instructionExpr = buildPasswordInstructionExpr(instruction);
3975
+ const optional = isObj ? step.optional === true : false;
3976
+ const upload = isObj ? step.upload === true : false;
3977
+ const submitStep = isObj ? step.submitStep === true : false;
3978
+ return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
3979
+ }
3849
3980
  const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary);
3850
3981
  if (field !== null)
3851
3982
  payloadFieldNames.add(field);
@@ -3869,7 +4000,7 @@ function emitBrowserFlowTs(opts) {
3869
4000
  // the multipart contract fields and wires the fixture during hand-finish.
3870
4001
  const uploadFixtureExpr = hasUploadStep && hasMultipartStep
3871
4002
  ? `{
3872
- buffer: Buffer.from(payload.Resume ?? "", "base64"),
4003
+ buffer: Buffer.from(payload.Resume),
3873
4004
  name: payload.ResumeFilename ?? "resume.pdf",
3874
4005
  mimeType: payload.ResumeContentType ?? "application/pdf",
3875
4006
  }`
@@ -3892,7 +4023,7 @@ import type { Stagehand } from "@browserbasehq/stagehand";
3892
4023
  import { z } from "zod/v4";
3893
4024
 
3894
4025
  import { buildAnthropicClient, buildRephraseModel } from "${ENGINE_PKG}/lib/llm/anthropic-client";
3895
- import { getLogger } from "${ENGINE_PKG}/lib/logging";
4026
+ import { getLogger } from "${ENGINE_PKG}/lib/logging";${usesThrowawayPassword ? `\nimport { generateThrowawayPassword } from "${ENGINE_PKG}/lib/random";` : ""}
3896
4027
  import { type HealingFlowStep, runHealingFlow, waitForSpaReady } from "${ENGINE_PKG}/scraper/flow-runner";
3897
4028
  import { guardedExtract } from "${ENGINE_PKG}/scraper/stagehand-guard";
3898
4029
  import type { ${pascal}Payload, ${pascal}Response } from "@/sites/${siteId}/contract";
@@ -3920,7 +4051,7 @@ export async function run${pascal}BrowserFlow(
3920
4051
  // networkidle can resolve before a Cloudflare-fronted SPA hydrates; wait for
3921
4052
  // the real DOM so the first steps don't probe an empty shell page and skip.
3922
4053
  await waitForSpaReady(page, logger);
3923
-
4054
+ ${usesThrowawayPassword ? "\n // Minted once per run — the flow needs a credential to authenticate, but\n // there is no caller-supplied Password field on the payload to splice.\n const throwawayPassword = generateThrowawayPassword();\n" : ""}
3924
4055
  const FLOW_STEPS: HealingFlowStep[] = [
3925
4056
  ${flowStepsBlock}
3926
4057
  ];