@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.
- package/README.md +34 -16
- package/dist/api-client.d.ts +11 -3
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +14 -4
- package/dist/api-client.js.map +1 -1
- package/dist/bot/browser.d.ts +19 -1
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +228 -30
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/near-text-hint.d.ts +7 -3
- package/dist/bot/near-text-hint.d.ts.map +1 -1
- package/dist/bot/near-text-hint.js +1 -2
- package/dist/bot/near-text-hint.js.map +1 -1
- package/dist/bot/operator-recipe.d.ts +694 -36
- package/dist/bot/operator-recipe.d.ts.map +1 -1
- package/dist/bot/operator-recipe.js +565 -43
- package/dist/bot/operator-recipe.js.map +1 -1
- package/dist/bot/pay-operator.d.ts +4 -2
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +173 -67
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +1 -0
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +20 -30
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/provision-session.d.ts +44 -1
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +1300 -104
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/eval/replay-harness/corpus.d.ts +7 -0
- package/dist/eval/replay-harness/corpus.d.ts.map +1 -0
- package/dist/eval/replay-harness/corpus.js +183 -0
- package/dist/eval/replay-harness/corpus.js.map +1 -0
- package/dist/eval/replay-harness/har-mutate.d.ts +36 -0
- package/dist/eval/replay-harness/har-mutate.d.ts.map +1 -0
- package/dist/eval/replay-harness/har-mutate.js +119 -0
- package/dist/eval/replay-harness/har-mutate.js.map +1 -0
- package/dist/eval/replay-harness/har-substrate.d.ts +16 -0
- package/dist/eval/replay-harness/har-substrate.d.ts.map +1 -0
- package/dist/eval/replay-harness/har-substrate.js +99 -0
- package/dist/eval/replay-harness/har-substrate.js.map +1 -0
- package/dist/eval/replay-harness/index.d.ts +8 -0
- package/dist/eval/replay-harness/index.d.ts.map +1 -0
- package/dist/eval/replay-harness/index.js +8 -0
- package/dist/eval/replay-harness/index.js.map +1 -0
- package/dist/eval/replay-harness/metrics.d.ts +9 -0
- package/dist/eval/replay-harness/metrics.d.ts.map +1 -0
- package/dist/eval/replay-harness/metrics.js +117 -0
- package/dist/eval/replay-harness/metrics.js.map +1 -0
- package/dist/eval/replay-harness/reporter.d.ts +4 -0
- package/dist/eval/replay-harness/reporter.d.ts.map +1 -0
- package/dist/eval/replay-harness/reporter.js +62 -0
- package/dist/eval/replay-harness/reporter.js.map +1 -0
- package/dist/eval/replay-harness/runner.d.ts +36 -0
- package/dist/eval/replay-harness/runner.d.ts.map +1 -0
- package/dist/eval/replay-harness/runner.js +167 -0
- package/dist/eval/replay-harness/runner.js.map +1 -0
- package/dist/eval/replay-harness/types.d.ts +135 -0
- package/dist/eval/replay-harness/types.d.ts.map +1 -0
- package/dist/eval/replay-harness/types.js +2 -0
- package/dist/eval/replay-harness/types.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/grant-app-access.js +1 -1
- package/dist/tools/grant-app-access.js.map +1 -1
- package/dist/tools/operate-pay.d.ts +10 -10
- package/dist/tools/operate-pay.d.ts.map +1 -1
- package/dist/tools/operate-pay.js +42 -16
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +165 -8
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +211 -40
- package/dist/tools/provision-drive.js.map +1 -1
- 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
|
|
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.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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",
|
|
26
|
-
"
|
|
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
|
-
//
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
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
|
|
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:
|
|
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
|
|
104
|
-
|
|
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
|
|
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
|
|
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":
|
|
135
|
-
|
|
136
|
-
case "
|
|
137
|
-
|
|
138
|
-
case "
|
|
139
|
-
|
|
140
|
-
case "
|
|
141
|
-
|
|
142
|
-
case "
|
|
143
|
-
|
|
144
|
-
case "
|
|
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: {
|
|
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
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
}
|