@trusty-squire/mcp 1.1.6 → 1.1.7

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.
Files changed (75) hide show
  1. package/README.md +34 -16
  2. package/dist/api-client.d.ts +11 -3
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +14 -4
  5. package/dist/api-client.js.map +1 -1
  6. package/dist/bot/browser.d.ts +19 -1
  7. package/dist/bot/browser.d.ts.map +1 -1
  8. package/dist/bot/browser.js +228 -30
  9. package/dist/bot/browser.js.map +1 -1
  10. package/dist/bot/near-text-hint.d.ts +7 -3
  11. package/dist/bot/near-text-hint.d.ts.map +1 -1
  12. package/dist/bot/near-text-hint.js +1 -2
  13. package/dist/bot/near-text-hint.js.map +1 -1
  14. package/dist/bot/operator-recipe.d.ts +694 -36
  15. package/dist/bot/operator-recipe.d.ts.map +1 -1
  16. package/dist/bot/operator-recipe.js +565 -43
  17. package/dist/bot/operator-recipe.js.map +1 -1
  18. package/dist/bot/pay-operator.d.ts +4 -2
  19. package/dist/bot/pay-operator.d.ts.map +1 -1
  20. package/dist/bot/pay-operator.js +173 -67
  21. package/dist/bot/pay-operator.js.map +1 -1
  22. package/dist/bot/promote-to-skill.d.ts +1 -0
  23. package/dist/bot/promote-to-skill.d.ts.map +1 -1
  24. package/dist/bot/promote-to-skill.js +20 -30
  25. package/dist/bot/promote-to-skill.js.map +1 -1
  26. package/dist/bot/provision-session.d.ts +44 -1
  27. package/dist/bot/provision-session.d.ts.map +1 -1
  28. package/dist/bot/provision-session.js +1300 -104
  29. package/dist/bot/provision-session.js.map +1 -1
  30. package/dist/eval/replay-harness/corpus.d.ts +7 -0
  31. package/dist/eval/replay-harness/corpus.d.ts.map +1 -0
  32. package/dist/eval/replay-harness/corpus.js +183 -0
  33. package/dist/eval/replay-harness/corpus.js.map +1 -0
  34. package/dist/eval/replay-harness/har-mutate.d.ts +36 -0
  35. package/dist/eval/replay-harness/har-mutate.d.ts.map +1 -0
  36. package/dist/eval/replay-harness/har-mutate.js +119 -0
  37. package/dist/eval/replay-harness/har-mutate.js.map +1 -0
  38. package/dist/eval/replay-harness/har-substrate.d.ts +16 -0
  39. package/dist/eval/replay-harness/har-substrate.d.ts.map +1 -0
  40. package/dist/eval/replay-harness/har-substrate.js +99 -0
  41. package/dist/eval/replay-harness/har-substrate.js.map +1 -0
  42. package/dist/eval/replay-harness/index.d.ts +8 -0
  43. package/dist/eval/replay-harness/index.d.ts.map +1 -0
  44. package/dist/eval/replay-harness/index.js +8 -0
  45. package/dist/eval/replay-harness/index.js.map +1 -0
  46. package/dist/eval/replay-harness/metrics.d.ts +9 -0
  47. package/dist/eval/replay-harness/metrics.d.ts.map +1 -0
  48. package/dist/eval/replay-harness/metrics.js +117 -0
  49. package/dist/eval/replay-harness/metrics.js.map +1 -0
  50. package/dist/eval/replay-harness/reporter.d.ts +4 -0
  51. package/dist/eval/replay-harness/reporter.d.ts.map +1 -0
  52. package/dist/eval/replay-harness/reporter.js +62 -0
  53. package/dist/eval/replay-harness/reporter.js.map +1 -0
  54. package/dist/eval/replay-harness/runner.d.ts +36 -0
  55. package/dist/eval/replay-harness/runner.d.ts.map +1 -0
  56. package/dist/eval/replay-harness/runner.js +167 -0
  57. package/dist/eval/replay-harness/runner.js.map +1 -0
  58. package/dist/eval/replay-harness/types.d.ts +135 -0
  59. package/dist/eval/replay-harness/types.d.ts.map +1 -0
  60. package/dist/eval/replay-harness/types.js +2 -0
  61. package/dist/eval/replay-harness/types.js.map +1 -0
  62. package/dist/server.d.ts.map +1 -1
  63. package/dist/server.js +1 -0
  64. package/dist/server.js.map +1 -1
  65. package/dist/tools/grant-app-access.js +1 -1
  66. package/dist/tools/grant-app-access.js.map +1 -1
  67. package/dist/tools/operate-pay.d.ts +10 -10
  68. package/dist/tools/operate-pay.d.ts.map +1 -1
  69. package/dist/tools/operate-pay.js +42 -16
  70. package/dist/tools/operate-pay.js.map +1 -1
  71. package/dist/tools/provision-drive.d.ts +165 -8
  72. package/dist/tools/provision-drive.d.ts.map +1 -1
  73. package/dist/tools/provision-drive.js +211 -40
  74. package/dist/tools/provision-drive.js.map +1 -1
  75. package/package.json +2 -1
@@ -1,13 +1,13 @@
1
1
  // operator-recipe.ts — Phase A of "user-saved operator workflows as skills"
2
2
  // (docs/ARCHITECTURE.md). A LOCAL artifact (deliberately NOT the
3
3
  // registry Skill schema yet — that bump is Phase B) that captures a successful
4
- // operate run so it can be replayed by name.
4
+ // operate run so it can be replayed by (verb, eTLD+1). Legacy named recipes
5
+ // remain readable.
5
6
  //
6
7
  // Three invariants baked in here:
7
- // 1. Text-based targeting only. A trace entry stores the VISIBLE text it
8
- // acted on, never a ref/coordinate operator targets are heavy SPAs whose
9
- // refs churn every observation, so literal playback would rot. The recipe
10
- // is a RAIL the planner re-drives along, not a script.
8
+ // 1. Stable-attribute targeting only. A trace stores authored DOM/semantic
9
+ // hints, never a ref/coordinate; visible text is the unique-only last
10
+ // fallback because operator targets are heavy SPAs whose refs churn.
11
11
  // 2. Sealed secrets are stored as SLOT REFERENCES, never values. A recipe
12
12
  // built from a session that sealed a secret records `{slot, stored:false}`
13
13
  // and an `extract`/`type_secret` step; the raw value never touches disk.
@@ -16,22 +16,102 @@
16
16
  // green principle (isCredentialNoise) one level up.
17
17
  import { z } from "zod";
18
18
  import { promises as fs } from "node:fs";
19
+ import { createHash } from "node:crypto";
19
20
  import os from "node:os";
20
21
  import path from "node:path";
22
+ import { getDomain } from "tldts";
23
+ import { filterByNearTextHint } from "./near-text-hint.js";
21
24
  // ── Schema ──────────────────────────────────────────────────────────
25
+ export const OperatorVerbSchema = z.enum([
26
+ "purchase",
27
+ "get_api_key",
28
+ "signup",
29
+ "subscribe",
30
+ "cancel",
31
+ "login",
32
+ "book",
33
+ "reserve",
34
+ "renew",
35
+ "upgrade",
36
+ "downgrade",
37
+ "add_to_cart",
38
+ "checkout",
39
+ "download",
40
+ "configure",
41
+ ]);
42
+ export const RecipeHoleSchema = z
43
+ .object({
44
+ hole: z
45
+ .string()
46
+ .regex(/^(?:address|contact|credential|card)(?:\.[a-zA-Z0-9_-]+)?$|^(?:product_query|quantity)$/),
47
+ })
48
+ .strict();
49
+ export const RecipeValueSchema = z.union([z.string().max(2000), RecipeHoleSchema]);
50
+ export const RecipeTargetSchema = z
51
+ .object({
52
+ dom_hint: z
53
+ .object({
54
+ testid: z.string().max(200).optional(),
55
+ id: z.string().max(200).optional(),
56
+ name: z.string().max(200).optional(),
57
+ })
58
+ .strict()
59
+ .optional(),
60
+ role_hint: z.string().max(80).optional(),
61
+ accessible_name: z.string().max(200).optional(),
62
+ near_text_hint: z.string().max(200).optional(),
63
+ href_hint: z.string().max(2000).optional(),
64
+ css: z.string().max(2000).optional(),
65
+ // Deliberately last-resort. Replay accepts this only when it is unique.
66
+ visible_text: z.string().max(200).optional(),
67
+ })
68
+ .strict();
69
+ const EmailHoleSchema = RecipeHoleSchema.shape.hole;
70
+ const EmailAliasTemplatePattern = /\$\{EMAIL_ALIAS((?:_(?:URI|CSS))*)\}/g;
71
+ function hasEmailAliasTemplate(value) {
72
+ return value?.includes("${EMAIL_ALIAS") === true;
73
+ }
74
+ const PostconditionUrlSchema = z
75
+ .string()
76
+ .max(2000)
77
+ .refine((value) => {
78
+ try {
79
+ new URL(value.replace(EmailAliasTemplatePattern, "buyer@example.com"));
80
+ return true;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }, "invalid URL");
22
86
  const TraceActionSchema = z
23
87
  .object({
24
88
  kind: z.enum([
25
- "goto", "click", "js_click", "type", "press", "oauth_click",
26
- "oauth_settle", "allow_host", "type_secret", "scroll", "extract",
89
+ "goto",
90
+ "click",
91
+ "js_click",
92
+ "type",
93
+ "press",
94
+ "oauth_click",
95
+ "oauth_settle",
96
+ "allow_host",
97
+ "type_secret",
98
+ "select",
99
+ "set_phone_country",
100
+ "operate_pay",
101
+ "scroll",
102
+ "extract",
27
103
  ]),
28
- // Visible text the action targeted (the rail). Never a ref/coordinate.
104
+ // Legacy visible-text rail. New recordings also carry `target`; this stays
105
+ // for backwards compatibility and is the unique-only final fallback.
29
106
  text_match: z.string().max(200).optional(),
107
+ target: RecipeTargetSchema.optional(),
108
+ email_hole: EmailHoleSchema.optional(),
30
109
  // goto: a URL with optional ${VAR} templates for per-run identity.
31
110
  url_template: z.string().max(2000).optional(),
32
- // type: the NON-secret value typed (names, emails, URIs). Secrets never
33
- // appear here they flow through `type_secret` + a slot.
34
- value: z.string().max(2000).optional(),
111
+ // Value-bearing actions store either a non-secret literal or provenance
112
+ // hole. Secret/card actions carry only a hole; their raw values never land
113
+ // in the recipe.
114
+ value: RecipeValueSchema.optional(),
35
115
  host: z.string().max(253).optional(),
36
116
  slot: z.string().max(60).optional(),
37
117
  direction: z.enum(["down", "up", "bottom", "top"]).optional(),
@@ -49,7 +129,12 @@ const SuccessSignalSchema = z.union([
49
129
  // A field/input whose label≈field_text holds a value at least N chars long
50
130
  // (e.g. OAuth Playground's "Access token"). We check the LENGTH, never the
51
131
  // value — the success signal must not leak the credential it proves.
52
- z.object({ field_text: z.string().min(1).max(120), min_value_len: z.number().int().positive().max(4096) }).strict(),
132
+ z
133
+ .object({
134
+ field_text: z.string().min(1).max(120),
135
+ min_value_len: z.number().int().positive().max(4096),
136
+ })
137
+ .strict(),
53
138
  // Visible page text contains this phrase.
54
139
  z.object({ text_present: z.string().min(1).max(200) }).strict(),
55
140
  // The current URL contains this substring (post-login path, etc.).
@@ -62,9 +147,23 @@ export const PostconditionSchema = z
62
147
  kind: z.enum(["execute_capability", "observe_artifact"]),
63
148
  describe: z.string().min(1).max(300),
64
149
  success_signal: SuccessSignalSchema,
65
- probe_url: z.string().url().max(2000).optional(),
150
+ probe_url: PostconditionUrlSchema.optional(),
151
+ email_hole: EmailHoleSchema.optional(),
66
152
  })
67
- .strict();
153
+ .strict()
154
+ .superRefine((postcondition, ctx) => {
155
+ const urlContains = "url_contains" in postcondition.success_signal
156
+ ? postcondition.success_signal.url_contains
157
+ : undefined;
158
+ if ((hasEmailAliasTemplate(postcondition.probe_url) || hasEmailAliasTemplate(urlContains)) &&
159
+ postcondition.email_hole === undefined) {
160
+ ctx.addIssue({
161
+ code: z.ZodIssueCode.custom,
162
+ path: ["email_hole"],
163
+ message: "known-email postcondition template lacks an attested source hole",
164
+ });
165
+ }
166
+ });
68
167
  const SecretRefSchema = z
69
168
  .object({
70
169
  slot: z.string().min(1).max(60),
@@ -79,6 +178,10 @@ export const OperatorRecipeSchema = z
79
178
  name: z.string().min(1).max(80),
80
179
  schema_version: z.literal(1),
81
180
  goal: z.string().min(1).max(300),
181
+ // New prepared-statement key. Optional only so existing local v1 recipes
182
+ // remain readable; new keyed recordings always set both fields.
183
+ verb: OperatorVerbSchema.optional(),
184
+ domain: z.string().min(1).max(253).optional(),
82
185
  // The canonical replay entry — the session's START url (the service_url
83
186
  // passed to operate_start). Optional for back-compat with recipes saved
84
187
  // before this field; recipeEntryUrl falls back to the first STABLE trace
@@ -86,12 +189,55 @@ export const OperatorRecipeSchema = z
86
189
  // mid-flow single-use links (a verify-email URL became the entry, so the
87
190
  // replay opened on an expired-token dead page — the plunk-recipe bug).
88
191
  entry_url: z.string().max(2000).optional(),
192
+ entry_mode: z.literal("runtime_service_url").optional(),
89
193
  allowed_hosts: z.array(z.string().max(253)).max(20).default([]),
90
194
  trace: z.array(TraceEntrySchema).max(200),
91
195
  secrets: z.array(SecretRefSchema).max(20).default([]),
92
196
  postcondition: PostconditionSchema,
93
197
  })
94
- .strict();
198
+ .strict()
199
+ .superRefine((recipe, ctx) => {
200
+ if ((recipe.verb === undefined) !== (recipe.domain === undefined)) {
201
+ ctx.addIssue({
202
+ code: z.ZodIssueCode.custom,
203
+ message: "operator recipe verb and domain must be present together",
204
+ });
205
+ }
206
+ if (recipe.entry_mode === "runtime_service_url" && recipe.entry_url !== undefined) {
207
+ ctx.addIssue({
208
+ code: z.ZodIssueCode.custom,
209
+ path: ["entry_url"],
210
+ message: "runtime-resolved operator recipes cannot persist an entry URL",
211
+ });
212
+ }
213
+ recipe.trace.forEach((entry, index) => {
214
+ const value = entry.action.value;
215
+ if (entry.action.kind === "operate_pay" &&
216
+ (value === undefined || typeof value === "string" || !/^card(?:\.|$)/.test(value.hole))) {
217
+ ctx.addIssue({
218
+ code: z.ZodIssueCode.custom,
219
+ path: ["trace", index, "action", "value"],
220
+ message: "operate_pay requires card provenance",
221
+ });
222
+ }
223
+ if (value === undefined || typeof value === "string")
224
+ return;
225
+ if (/^card(?:\.|$)/.test(value.hole) && entry.action.kind !== "operate_pay") {
226
+ ctx.addIssue({
227
+ code: z.ZodIssueCode.custom,
228
+ path: ["trace", index, "action", "value"],
229
+ message: "card provenance is only valid on operate_pay",
230
+ });
231
+ }
232
+ if (/^credential(?:\.|$)/.test(value.hole) && entry.action.kind !== "type_secret") {
233
+ ctx.addIssue({
234
+ code: z.ZodIssueCode.custom,
235
+ path: ["trace", index, "action", "value"],
236
+ message: "credential provenance is only valid on type_secret",
237
+ });
238
+ }
239
+ });
240
+ });
95
241
  // ── Local IO ────────────────────────────────────────────────────────
96
242
  export function operatorRecipeDir() {
97
243
  const fromEnv = process.env.TRUSTY_SQUIRE_OPERATOR_RECIPE_DIR;
@@ -100,8 +246,31 @@ export function operatorRecipeDir() {
100
246
  return path.join(os.homedir(), ".trusty-squire", "operator-recipes");
101
247
  }
102
248
  function safeFileName(name) {
103
- const slug = name.toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
104
- return slug.length > 0 ? slug : "recipe";
249
+ const slug = name
250
+ .toLowerCase()
251
+ .replace(/[^a-z0-9._-]/g, "-")
252
+ .replace(/^-+|-+$/g, "");
253
+ if (slug.length === 0)
254
+ return "recipe";
255
+ if (slug.length <= 80)
256
+ return slug;
257
+ // DNS names may exceed the legacy 80-char recipe-name cap. Preserve the
258
+ // readable prefix but hash the tail so two long tenant/domain keys cannot
259
+ // silently overwrite one another.
260
+ const digest = createHash("sha256").update(slug).digest("hex").slice(0, 16);
261
+ return `${slug.slice(0, 63)}-${digest}`;
262
+ }
263
+ const TENANT_HOST_SUFFIXES = ["myshopify.com", "notion.site"];
264
+ /** Public-Suffix-List-backed local recipe domain; registry service slugs are unrelated. */
265
+ export function operatorRecipeDomain(url) {
266
+ const hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, "");
267
+ const tenantSuffix = TENANT_HOST_SUFFIXES.find((suffix) => hostname !== suffix && hostname.endsWith(`.${suffix}`));
268
+ if (tenantSuffix !== undefined)
269
+ return hostname;
270
+ return getDomain(hostname, { allowPrivateDomains: false }) ?? hostname;
271
+ }
272
+ export function operatorRecipeKey(verb, url) {
273
+ return `${verb}--${operatorRecipeDomain(url)}`;
105
274
  }
106
275
  export async function writeRecipe(recipe) {
107
276
  // Validate (and, crucially, re-assert the no-stored-value invariant) before
@@ -109,7 +278,10 @@ export async function writeRecipe(recipe) {
109
278
  const parsed = OperatorRecipeSchema.parse(recipe);
110
279
  const dir = operatorRecipeDir();
111
280
  await fs.mkdir(dir, { recursive: true });
112
- const file = path.join(dir, `${safeFileName(parsed.name)}.json`);
281
+ const fileStem = parsed.verb !== undefined && parsed.domain !== undefined
282
+ ? `${parsed.verb}--${parsed.domain}`
283
+ : parsed.name;
284
+ const file = path.join(dir, `${safeFileName(fileStem)}.json`);
113
285
  await fs.writeFile(file, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
114
286
  return file;
115
287
  }
@@ -118,10 +290,16 @@ export async function readRecipe(name) {
118
290
  const raw = await fs.readFile(file, "utf8");
119
291
  return OperatorRecipeSchema.parse(JSON.parse(raw));
120
292
  }
293
+ export async function readRecipeForTask(verb, serviceUrl) {
294
+ return await readRecipe(operatorRecipeKey(verb, serviceUrl));
295
+ }
121
296
  export async function listRecipes() {
122
297
  try {
123
298
  const files = await fs.readdir(operatorRecipeDir());
124
- return files.filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).sort();
299
+ return files
300
+ .filter((f) => f.endsWith(".json"))
301
+ .map((f) => f.slice(0, -5))
302
+ .sort();
125
303
  }
126
304
  catch {
127
305
  return [];
@@ -130,18 +308,40 @@ export async function listRecipes() {
130
308
  // ── Rail render (the "MAP, not a script") ───────────────────────────
131
309
  function describeAction(a) {
132
310
  const t = a.text_match !== undefined ? `"${a.text_match}"` : "";
311
+ const v = typeof a.value === "string"
312
+ ? `"${a.value}"`
313
+ : a.value !== undefined
314
+ ? `{${a.value.hole}}`
315
+ : undefined;
133
316
  switch (a.kind) {
134
- case "goto": return `go to ${a.url_template ?? ""}`;
135
- case "click": return `click ${t}`;
136
- case "js_click": return `click ${t} (JS-dispatch if a plain click doesn't register)`;
137
- case "type": return `type ${a.value !== undefined ? `"${a.value}" ` : ""}into ${t}`;
138
- case "press": return `press ${a.key ?? ""}`;
139
- case "oauth_click": return `click the OAuth button ${t}`;
140
- case "oauth_settle": return `complete the OAuth handshake`;
141
- case "allow_host": return `cross into ${a.host ?? ""}`;
142
- case "type_secret": return `type the sealed secret (slot ${a.slot ?? "?"}) into ${t}`;
143
- case "scroll": return `scroll ${a.direction ?? "down"}`;
144
- case "extract": return `reveal + seal the secret into slot ${a.slot ?? "?"}`;
317
+ case "goto":
318
+ return `go to ${a.url_template ?? ""}`;
319
+ case "click":
320
+ return `click ${t}`;
321
+ case "js_click":
322
+ return `click ${t} (JS-dispatch if a plain click doesn't register)`;
323
+ case "type":
324
+ return `type ${v !== undefined ? `${v} ` : ""}into ${t}`;
325
+ case "press":
326
+ return `press ${a.key ?? ""}`;
327
+ case "oauth_click":
328
+ return `click the OAuth button ${t}`;
329
+ case "oauth_settle":
330
+ return `complete the OAuth handshake`;
331
+ case "allow_host":
332
+ return `cross into ${a.host ?? ""}`;
333
+ case "type_secret":
334
+ return `type the sealed secret (slot ${a.slot ?? "?"}) into ${t}`;
335
+ case "select":
336
+ return `select ${v ?? ""} in ${t}`;
337
+ case "set_phone_country":
338
+ return `set the phone country to ${v ?? ""}`;
339
+ case "operate_pay":
340
+ return `pay with ${v ?? "{card}"}`;
341
+ case "scroll":
342
+ return `scroll ${a.direction ?? "down"}`;
343
+ case "extract":
344
+ return `reveal + seal the secret into slot ${a.slot ?? "?"}`;
145
345
  }
146
346
  }
147
347
  export function renderOperatorRecipeHint(recipe) {
@@ -180,14 +380,20 @@ export function checkSuccessSignal(signal, snap) {
180
380
  : field === undefined
181
381
  ? `no field matching "${signal.field_text}"`
182
382
  : `field "${signal.field_text}" value too short (${field.value_len} < ${signal.min_value_len})`,
183
- evidence: { field: signal.field_text, value_len: field?.value_len ?? 0, required: signal.min_value_len },
383
+ evidence: {
384
+ field: signal.field_text,
385
+ value_len: field?.value_len ?? 0,
386
+ required: signal.min_value_len,
387
+ },
184
388
  };
185
389
  }
186
390
  if ("text_present" in signal) {
187
391
  const ok = norm(snap.text).includes(norm(signal.text_present));
188
392
  return {
189
393
  confirmed: ok,
190
- reason: ok ? `page text contains "${signal.text_present}"` : `page text missing "${signal.text_present}"`,
394
+ reason: ok
395
+ ? `page text contains "${signal.text_present}"`
396
+ : `page text missing "${signal.text_present}"`,
191
397
  evidence: { text_present: signal.text_present },
192
398
  };
193
399
  }
@@ -212,6 +418,316 @@ export function fillTemplate(template, params) {
212
418
  });
213
419
  return { url, missing };
214
420
  }
421
+ function flattenKnownInputs(inputs) {
422
+ const out = [];
423
+ const addGroup = (prefix, value) => {
424
+ if (typeof value === "string") {
425
+ out.push([prefix, value]);
426
+ return;
427
+ }
428
+ if (value === undefined)
429
+ return;
430
+ for (const [field, fieldValue] of Object.entries(value)) {
431
+ out.push([`${prefix}.${field}`, fieldValue]);
432
+ }
433
+ };
434
+ addGroup("address", inputs.address);
435
+ addGroup("contact", inputs.contact);
436
+ if (inputs.product_query !== undefined)
437
+ out.push(["product_query", inputs.product_query]);
438
+ addGroup("credential", inputs.credential);
439
+ addGroup("card", inputs.card);
440
+ if (inputs.quantity !== undefined)
441
+ out.push(["quantity", String(inputs.quantity)]);
442
+ return out;
443
+ }
444
+ export function knownRecipeInputValue(inputs, hole) {
445
+ return flattenKnownInputs(inputs).find(([candidate]) => candidate === hole)?.[1];
446
+ }
447
+ export function knownRecipeInputHolesForValue(inputs, value) {
448
+ return flattenKnownInputs(inputs)
449
+ .filter(([, known]) => known === value)
450
+ .map(([hole]) => hole);
451
+ }
452
+ /** Tag only exact, caller-proven values. Unknown literals deliberately remain literals. */
453
+ export function tagProvenanceValue(value, inputs) {
454
+ const knownInputs = flattenKnownInputs(inputs);
455
+ const matches = knownInputs.filter(([, known]) => known === value);
456
+ if (matches.length === 0 && value === "${EMAIL_ALIAS}") {
457
+ matches.push(...knownInputs.filter(([hole, known]) => /^(?:address|contact|credential)(?:\.|$)/.test(hole) &&
458
+ /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(known)));
459
+ }
460
+ const holes = [...new Set(matches.map(([hole]) => hole))];
461
+ if (holes.length > 1) {
462
+ throw new Error(`ambiguous recipe provenance across holes: ${holes.join(", ")}`);
463
+ }
464
+ return holes[0] === undefined ? value : { hole: holes[0] };
465
+ }
466
+ export function tagTraceProvenance(trace, inputs) {
467
+ return trace.map((entry) => {
468
+ const value = entry.action.value;
469
+ if (typeof value !== "string")
470
+ return entry;
471
+ return {
472
+ ...entry,
473
+ action: { ...entry.action, value: tagProvenanceValue(value, inputs) },
474
+ };
475
+ });
476
+ }
477
+ export function bindRecipeValue(value, bindings) {
478
+ if (typeof value === "string") {
479
+ const filled = fillTemplate(value, bindings);
480
+ if (filled.missing.length > 0) {
481
+ throw new Error(`missing recipe binding: ${filled.missing.join(", ")}`);
482
+ }
483
+ return filled.url;
484
+ }
485
+ const bound = bindings[value.hole];
486
+ if (bound === undefined)
487
+ throw new Error(`missing recipe binding: ${value.hole}`);
488
+ return bound;
489
+ }
490
+ export function bindRecipeTarget(target, bindings, emailHole) {
491
+ const bind = (value) => value === undefined ? undefined : bindKnownEmailTemplate(value, bindings, emailHole);
492
+ const domHint = target.dom_hint;
493
+ return {
494
+ ...(domHint !== undefined
495
+ ? {
496
+ dom_hint: {
497
+ ...(bind(domHint.testid) !== undefined ? { testid: bind(domHint.testid) } : {}),
498
+ ...(bind(domHint.id) !== undefined ? { id: bind(domHint.id) } : {}),
499
+ ...(bind(domHint.name) !== undefined ? { name: bind(domHint.name) } : {}),
500
+ },
501
+ }
502
+ : {}),
503
+ ...(bind(target.role_hint) !== undefined ? { role_hint: bind(target.role_hint) } : {}),
504
+ ...(bind(target.accessible_name) !== undefined
505
+ ? { accessible_name: bind(target.accessible_name) }
506
+ : {}),
507
+ ...(bind(target.near_text_hint) !== undefined
508
+ ? { near_text_hint: bind(target.near_text_hint) }
509
+ : {}),
510
+ ...(bind(target.href_hint) !== undefined ? { href_hint: bind(target.href_hint) } : {}),
511
+ ...(bind(target.css) !== undefined ? { css: bind(target.css) } : {}),
512
+ ...(bind(target.visible_text) !== undefined
513
+ ? { visible_text: bind(target.visible_text) }
514
+ : {}),
515
+ };
516
+ }
517
+ export function bindKnownEmailTemplate(value, bindings, emailHole) {
518
+ if (!value.includes("${EMAIL_ALIAS"))
519
+ return value;
520
+ if (emailHole === undefined) {
521
+ throw new Error("email template lacks an attested source hole");
522
+ }
523
+ const email = bindings[emailHole];
524
+ if (email === undefined)
525
+ throw new Error(`missing recipe binding: ${emailHole}`);
526
+ return value.replace(EmailAliasTemplatePattern, (_token, suffix) => suffix
527
+ .split("_")
528
+ .filter(Boolean)
529
+ .reduce((bound, operation) => operation === "URI" ? encodeURIComponent(bound) : cssEscapeRecipeValue(bound), email));
530
+ }
531
+ export function bindRecipePostcondition(postcondition, bindings) {
532
+ const bind = (value) => value === undefined
533
+ ? undefined
534
+ : bindKnownEmailTemplate(value, bindings, postcondition.email_hole);
535
+ const successSignal = postcondition.success_signal;
536
+ const probeUrl = bind(postcondition.probe_url);
537
+ return {
538
+ ...postcondition,
539
+ ...(probeUrl !== undefined ? { probe_url: probeUrl } : {}),
540
+ success_signal: "url_contains" in successSignal
541
+ ? { url_contains: bind(successSignal.url_contains) }
542
+ : successSignal,
543
+ };
544
+ }
545
+ export function cssEscapeRecipeValue(value) {
546
+ const chars = [...value];
547
+ return chars
548
+ .map((char, index) => {
549
+ const code = char.codePointAt(0);
550
+ if (code === 0)
551
+ return "�";
552
+ if ((code >= 1 && code <= 31) ||
553
+ code === 127 ||
554
+ (index === 0 && code >= 48 && code <= 57) ||
555
+ (index === 1 && code >= 48 && code <= 57 && chars[0] === "-")) {
556
+ return `\\${code.toString(16)} `;
557
+ }
558
+ if (index === 0 && char === "-" && chars.length === 1)
559
+ return "\\-";
560
+ if (code >= 128 ||
561
+ char === "-" ||
562
+ char === "_" ||
563
+ (code >= 48 && code <= 57) ||
564
+ (code >= 65 && code <= 90) ||
565
+ (code >= 97 && code <= 122)) {
566
+ return char;
567
+ }
568
+ return `\\${char}`;
569
+ })
570
+ .join("");
571
+ }
572
+ const targetNorm = (value) => (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
573
+ function accessibleName(element) {
574
+ return targetNorm(element.ariaLabel ??
575
+ element.labelText ??
576
+ element.visibleText ??
577
+ element.iconLabel ??
578
+ element.placeholder ??
579
+ element.title ??
580
+ element.name);
581
+ }
582
+ function semanticRole(element) {
583
+ if (element.role !== null && element.role !== undefined)
584
+ return targetNorm(element.role);
585
+ if (element.tag === "button")
586
+ return "button";
587
+ if (element.tag === "a")
588
+ return "link";
589
+ if (element.tag === "input" || element.tag === "textarea")
590
+ return "textbox";
591
+ return targetNorm(element.tag);
592
+ }
593
+ /** Ordered fallback. It is intentionally not a weighted/scored matcher. */
594
+ export function resolveRecipeTarget(elements, target) {
595
+ const first = (matches) => {
596
+ if (matches.length === 1)
597
+ return matches[0];
598
+ if (target.near_text_hint === undefined || matches.length === 0)
599
+ return undefined;
600
+ const narrowed = filterByNearTextHint(matches, target.near_text_hint, elements);
601
+ return narrowed.length === 1 ? narrowed[0] : undefined;
602
+ };
603
+ if (target.dom_hint?.testid !== undefined) {
604
+ const element = first(elements.filter((candidate) => candidate.testId === target.dom_hint?.testid));
605
+ if (element !== undefined)
606
+ return { element, via: "testid" };
607
+ }
608
+ if (target.dom_hint?.id !== undefined) {
609
+ const element = first(elements.filter((candidate) => candidate.id === target.dom_hint?.id));
610
+ if (element !== undefined)
611
+ return { element, via: "id" };
612
+ }
613
+ if (target.dom_hint?.name !== undefined) {
614
+ const element = first(elements.filter((candidate) => candidate.name === target.dom_hint?.name));
615
+ if (element !== undefined)
616
+ return { element, via: "name" };
617
+ }
618
+ if (target.role_hint !== undefined && target.accessible_name !== undefined) {
619
+ const wantedRole = targetNorm(target.role_hint);
620
+ const wantedName = targetNorm(target.accessible_name);
621
+ const element = first(elements.filter((candidate) => semanticRole(candidate) === wantedRole && accessibleName(candidate) === wantedName));
622
+ if (element !== undefined)
623
+ return { element, via: "role+accessible-name" };
624
+ }
625
+ if (target.href_hint !== undefined) {
626
+ const hrefPath = (value) => {
627
+ try {
628
+ return new URL(value, "https://recipe.invalid").pathname;
629
+ }
630
+ catch {
631
+ return value;
632
+ }
633
+ };
634
+ const wantedHref = hrefPath(target.href_hint);
635
+ const element = first(elements.filter((candidate) => candidate.href !== null &&
636
+ candidate.href !== undefined &&
637
+ hrefPath(candidate.href) === wantedHref));
638
+ if (element !== undefined)
639
+ return { element, via: "href" };
640
+ }
641
+ if (target.css !== undefined) {
642
+ const element = first(elements.filter((candidate) => candidate.selector === target.css));
643
+ if (element !== undefined)
644
+ return { element, via: "css" };
645
+ }
646
+ if (target.visible_text !== undefined) {
647
+ const wanted = targetNorm(target.visible_text);
648
+ const matches = elements.filter((candidate) => targetNorm(candidate.visibleText) === wanted);
649
+ if (matches.length === 1)
650
+ return { element: matches[0], via: "visible-text" };
651
+ }
652
+ return null;
653
+ }
654
+ export function resolveRecipeFieldTarget(elements, target) {
655
+ const resolution = resolveRecipeTarget(elements, target);
656
+ if (resolution === null)
657
+ return null;
658
+ return resolution.via === "testid" || resolution.via === "id" ? resolution : null;
659
+ }
660
+ export function hasRecipeTargetCandidate(elements, target) {
661
+ const wantedAccessibleName = targetNorm(target.accessible_name);
662
+ const wantedVisibleText = targetNorm(target.visible_text);
663
+ return elements.some((candidate) => {
664
+ if (target.dom_hint?.testid !== undefined && candidate.testId === target.dom_hint.testid) {
665
+ return true;
666
+ }
667
+ if (target.dom_hint?.id !== undefined && candidate.id === target.dom_hint.id)
668
+ return true;
669
+ if (target.dom_hint?.name !== undefined && candidate.name === target.dom_hint.name)
670
+ return true;
671
+ if (target.role_hint !== undefined &&
672
+ target.accessible_name !== undefined &&
673
+ semanticRole(candidate) === targetNorm(target.role_hint) &&
674
+ accessibleName(candidate) === wantedAccessibleName) {
675
+ return true;
676
+ }
677
+ if (target.href_hint !== undefined && candidate.href === target.href_hint)
678
+ return true;
679
+ if (target.css !== undefined && candidate.selector === target.css)
680
+ return true;
681
+ return (target.visible_text !== undefined && targetNorm(candidate.visibleText) === wantedVisibleText);
682
+ });
683
+ }
684
+ export function resolveRecipeRepairTarget(elements, target) {
685
+ let candidates = [...elements];
686
+ let constrained = false;
687
+ if (target.dom_hint?.name !== undefined) {
688
+ candidates = candidates.filter((candidate) => candidate.name === target.dom_hint?.name);
689
+ constrained = true;
690
+ }
691
+ if (target.role_hint !== undefined) {
692
+ const wantedRole = targetNorm(target.role_hint);
693
+ candidates = candidates.filter((candidate) => semanticRole(candidate) === wantedRole);
694
+ constrained = true;
695
+ }
696
+ if (target.accessible_name !== undefined) {
697
+ const wantedName = targetNorm(target.accessible_name);
698
+ candidates = candidates.filter((candidate) => accessibleName(candidate) === wantedName);
699
+ constrained = true;
700
+ }
701
+ if (target.visible_text !== undefined) {
702
+ const wantedText = targetNorm(target.visible_text);
703
+ candidates = candidates.filter((candidate) => targetNorm(candidate.visibleText) === wantedText);
704
+ constrained = true;
705
+ }
706
+ if (target.near_text_hint !== undefined) {
707
+ candidates = filterByNearTextHint(candidates, target.near_text_hint, elements);
708
+ constrained = true;
709
+ }
710
+ return constrained && candidates.length === 1 ? (candidates[0] ?? null) : null;
711
+ }
712
+ /** Money-path guard: exact live values must equal every value Squire injected. */
713
+ export function verifyFilledFieldValues(elements, expectedFields) {
714
+ for (const expected of expectedFields) {
715
+ const resolved = resolveRecipeFieldTarget(elements, expected.target);
716
+ const field = expected.hole ?? expected.target.accessible_name ?? expected.target.dom_hint?.name ?? "field";
717
+ if (resolved === null)
718
+ return { ok: false, reason: "field_missing", field };
719
+ const actual = expected.kind === "select"
720
+ ? (resolved.element.selectedOptionText ??
721
+ resolved.element.value ??
722
+ resolved.element.visibleText ??
723
+ "")
724
+ : (resolved.element.value ?? "");
725
+ if (actual !== expected.expected) {
726
+ return { ok: false, reason: "field_value_mismatch", field };
727
+ }
728
+ }
729
+ return { ok: true };
730
+ }
215
731
  // A URL that carries a ONE-TIME credential in its path/query — email
216
732
  // verification links, magic links, password-reset links, signup-confirm
217
733
  // tokens. These are single-use: freezing one into a recipe makes the replay
@@ -224,15 +740,14 @@ const SINGLE_USE_TOKEN_PARAM = /[?&](?:token|code|key|oobcode|confirmation_token
224
740
  // A long, token-shaped string: not a human word — long, token charset, and
225
741
  // mixes letters+digits (so "verify-email" or "confirmation" don't qualify).
226
742
  function looksOpaqueToken(s) {
227
- return (s.length >= 16 &&
228
- /^[A-Za-z0-9._-]+$/.test(s) &&
229
- /[0-9]/.test(s) &&
230
- /[A-Za-z]/.test(s));
743
+ return s.length >= 16 && /^[A-Za-z0-9._-]+$/.test(s) && /[0-9]/.test(s) && /[A-Za-z]/.test(s);
231
744
  }
232
745
  export function isSingleUseUrl(rawUrl) {
233
746
  let u;
234
747
  try {
235
- u = new URL(rawUrl);
748
+ // A captured href is often relative; the placeholder base lets the same
749
+ // token transform cover both href hints and absolute goto URLs.
750
+ u = new URL(rawUrl, "https://recipe.invalid");
236
751
  }
237
752
  catch {
238
753
  return false;
@@ -245,20 +760,27 @@ export function isSingleUseUrl(rawUrl) {
245
760
  return true; // token in the query
246
761
  return u.pathname.split("/").some(looksOpaqueToken); // or in a path segment
247
762
  }
248
- export function recipeEntryUrl(recipe) {
763
+ export function recipeEntryUrl(recipe, runtimeServiceUrl) {
764
+ if (recipe.entry_mode === "runtime_service_url") {
765
+ if (runtimeServiceUrl === undefined)
766
+ return null;
767
+ if (recipe.domain === undefined || operatorRecipeDomain(runtimeServiceUrl) !== recipe.domain) {
768
+ throw new Error(`runtime service URL domain does not match operator-recipe "${recipe.name}"`);
769
+ }
770
+ return runtimeServiceUrl;
771
+ }
249
772
  // Prefer the recipe's canonical entry (the session's start URL). Recipes
250
773
  // synthesized before entry_url existed fall back to the first STABLE trace
251
774
  // goto — skipping single-use verification/magic links, which must never be a
252
775
  // replay entry (opening one lands on an expired-token dead page).
253
- if (recipe.entry_url !== undefined && recipe.entry_url.length > 0) {
776
+ if (recipe.entry_url !== undefined &&
777
+ recipe.entry_url.length > 0 &&
778
+ !isSingleUseUrl(recipe.entry_url)) {
254
779
  return recipe.entry_url;
255
780
  }
256
781
  const firstStableGoto = recipe.trace.find((t) => {
257
782
  const url = t.action.url_template;
258
- return (t.action.kind === "goto" &&
259
- url !== undefined &&
260
- url.length > 0 &&
261
- !isSingleUseUrl(url));
783
+ return t.action.kind === "goto" && url !== undefined && url.length > 0 && !isSingleUseUrl(url);
262
784
  });
263
785
  return firstStableGoto?.action.url_template ?? null;
264
786
  }