imprint-mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +168 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/examples/discoverandgo/README.md +57 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
- package/examples/echo/README.md +37 -0
- package/examples/echo/echo_test/index.ts +31 -0
- package/examples/google-flights/search_google_flights/index.ts +101 -0
- package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
- package/examples/google-flights/search_google_flights/parser.ts +189 -0
- package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
- package/examples/google-flights/search_google_flights/workflow.json +48 -0
- package/examples/google-hotels/search_google_hotels/index.ts +194 -0
- package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
- package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
- package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
- package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
- package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
- package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
- package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
- package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
- package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
- package/examples/southwest/README.md +81 -0
- package/examples/southwest/search_southwest_flights/backends.json +23 -0
- package/examples/southwest/search_southwest_flights/cron.json +19 -0
- package/examples/southwest/search_southwest_flights/index.ts +110 -0
- package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
- package/examples/southwest/search_southwest_flights/workflow.json +54 -0
- package/package.json +78 -0
- package/prompts/compile-agent.md +580 -0
- package/prompts/intent-detection.md +198 -0
- package/prompts/playbook-compilation.md +279 -0
- package/prompts/request-triage.md +74 -0
- package/prompts/tool-candidate-detection.md +104 -0
- package/src/cli.ts +1287 -0
- package/src/imprint/agent.ts +468 -0
- package/src/imprint/app-api-hosts.ts +53 -0
- package/src/imprint/backend-ladder.ts +568 -0
- package/src/imprint/check.ts +136 -0
- package/src/imprint/chromium.ts +211 -0
- package/src/imprint/claude-cli-compile.ts +640 -0
- package/src/imprint/cli-credential.ts +394 -0
- package/src/imprint/codex-cli-compile.ts +712 -0
- package/src/imprint/compile-agent-types.ts +40 -0
- package/src/imprint/compile-agent.ts +404 -0
- package/src/imprint/compile-tools.ts +1389 -0
- package/src/imprint/compile.ts +720 -0
- package/src/imprint/cookie-jar.ts +246 -0
- package/src/imprint/credential-bundle.ts +195 -0
- package/src/imprint/credential-extract.ts +290 -0
- package/src/imprint/credential-store.ts +707 -0
- package/src/imprint/cron.ts +312 -0
- package/src/imprint/doctor.ts +223 -0
- package/src/imprint/emit.ts +154 -0
- package/src/imprint/etld.ts +134 -0
- package/src/imprint/freeform-redact.ts +216 -0
- package/src/imprint/inject-listener.ts +137 -0
- package/src/imprint/install.ts +795 -0
- package/src/imprint/integrations.ts +385 -0
- package/src/imprint/is-compiled.ts +2 -0
- package/src/imprint/json-path.ts +100 -0
- package/src/imprint/llm.ts +998 -0
- package/src/imprint/load-json.ts +54 -0
- package/src/imprint/log.ts +33 -0
- package/src/imprint/login.ts +166 -0
- package/src/imprint/mcp-compile-server.ts +282 -0
- package/src/imprint/mcp-maintenance.ts +1790 -0
- package/src/imprint/mcp-server.ts +350 -0
- package/src/imprint/multi-progress.ts +69 -0
- package/src/imprint/notify.ts +155 -0
- package/src/imprint/paths.ts +64 -0
- package/src/imprint/playbook-parser.ts +21 -0
- package/src/imprint/playbook-runner.ts +465 -0
- package/src/imprint/probe-backends.ts +251 -0
- package/src/imprint/progress.ts +28 -0
- package/src/imprint/record.ts +470 -0
- package/src/imprint/redact.ts +550 -0
- package/src/imprint/replay-capture.ts +387 -0
- package/src/imprint/request-context.ts +66 -0
- package/src/imprint/runtime-link.ts +73 -0
- package/src/imprint/runtime.ts +942 -0
- package/src/imprint/sensitive-keys.ts +156 -0
- package/src/imprint/session-diff.ts +409 -0
- package/src/imprint/session-merge.ts +198 -0
- package/src/imprint/session-writer.ts +149 -0
- package/src/imprint/sites.ts +27 -0
- package/src/imprint/stealth-fetch.ts +434 -0
- package/src/imprint/teach-state.ts +235 -0
- package/src/imprint/teach.ts +2120 -0
- package/src/imprint/tool-candidates.ts +423 -0
- package/src/imprint/tool-loader.ts +186 -0
- package/src/imprint/tool-selection.ts +70 -0
- package/src/imprint/tracing.ts +508 -0
- package/src/imprint/types.ts +472 -0
- package/src/imprint/version.ts +21 -0
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow execution engine — substitutes ${param/credential/env/response[N]}
|
|
3
|
+
* placeholders, loads cookies from the site credential store, runs the
|
|
4
|
+
* chain sequentially, returns a classified ToolResult. Generated tool
|
|
5
|
+
* files are thin wrappers around executeWorkflow().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { dirname, resolve as pathResolve } from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
type CookieLookupConstraints,
|
|
11
|
+
type RuntimeCookie,
|
|
12
|
+
RuntimeCookieJar,
|
|
13
|
+
extractSetCookieHeaders,
|
|
14
|
+
} from './cookie-jar.ts';
|
|
15
|
+
import { type StorageRecord, loadSiteCredentials, readSiteManifest } from './credential-store.ts';
|
|
16
|
+
import type {
|
|
17
|
+
RequestCapture,
|
|
18
|
+
StateCapability,
|
|
19
|
+
StateMissingItem,
|
|
20
|
+
ToolResult,
|
|
21
|
+
Workflow,
|
|
22
|
+
WorkflowRequest,
|
|
23
|
+
} from './types.ts';
|
|
24
|
+
|
|
25
|
+
export { splitSetCookieHeader } from './cookie-jar.ts';
|
|
26
|
+
|
|
27
|
+
export interface CredentialStore {
|
|
28
|
+
site: string;
|
|
29
|
+
/** Persisted via `imprint login`; sent on every same-domain request. */
|
|
30
|
+
cookies: RuntimeCookie[];
|
|
31
|
+
/** ${credential.X} substitutions (patron_id, csrf_token, etc). */
|
|
32
|
+
values: Record<string, string>;
|
|
33
|
+
/** Durable browser storage captured by `imprint login`; V1 seeds localStorage only. */
|
|
34
|
+
storage?: StorageRecord[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Load credentials for a site from the credential manager (OS keychain →
|
|
38
|
+
* encrypted-file fallback → legacy JSON for backwards compat). Returns
|
|
39
|
+
* null only if there's truly nothing recorded; a missing keychain entry
|
|
40
|
+
* with no legacy file still yields an empty store. */
|
|
41
|
+
export async function loadCredentialStore(site: string): Promise<CredentialStore | null> {
|
|
42
|
+
const view = await loadSiteCredentials(site);
|
|
43
|
+
const store: CredentialStore = {
|
|
44
|
+
site: view.site,
|
|
45
|
+
cookies: view.cookies,
|
|
46
|
+
values: { ...view.values },
|
|
47
|
+
storage: view.storage,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const envCreds = process.env.IMPRINT_TEACH_CREDENTIALS;
|
|
51
|
+
if (envCreds) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(envCreds) as { site: string; values: Record<string, string> };
|
|
54
|
+
if (parsed.site === site && parsed.values) {
|
|
55
|
+
for (const [k, v] of Object.entries(parsed.values)) {
|
|
56
|
+
if (!(k in store.values)) store.values[k] = v;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Malformed env var — ignore silently.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
Object.keys(store.values).length === 0 &&
|
|
66
|
+
store.cookies.length === 0 &&
|
|
67
|
+
(store.storage?.length ?? 0) === 0
|
|
68
|
+
) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return store;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ExecuteOptions {
|
|
75
|
+
workflow: Workflow;
|
|
76
|
+
params: Record<string, string | number | boolean>;
|
|
77
|
+
/** Inject a synthetic credential store; otherwise loads from disk. */
|
|
78
|
+
credentials?: CredentialStore;
|
|
79
|
+
/** Override global fetch (tests, stealth-fetch). */
|
|
80
|
+
fetchImpl?: typeof fetch;
|
|
81
|
+
/** Per-request timeout in ms. Default 30000. */
|
|
82
|
+
requestTimeoutMs?: number;
|
|
83
|
+
/** Absolute path of workflow.json — required for parserModule resolution. */
|
|
84
|
+
workflowPath?: string;
|
|
85
|
+
/** Initial ${state.X} values harvested by fetch-bootstrap. */
|
|
86
|
+
initialState?: Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ResponseSlot {
|
|
90
|
+
raw: unknown;
|
|
91
|
+
aliases: Record<string, unknown>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promise<ToolResult<T>> {
|
|
95
|
+
const fetchFn = opts.fetchImpl ?? fetch;
|
|
96
|
+
const timeoutMs = opts.requestTimeoutMs ?? 30_000;
|
|
97
|
+
|
|
98
|
+
// A zero-request workflow would silently return null data — almost
|
|
99
|
+
// certainly a misconfigured workflow (LLM produced an empty `requests`
|
|
100
|
+
// array). Fail loud so the user knows to re-record or re-generate.
|
|
101
|
+
if (opts.workflow.requests.length === 0) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: 'UNKNOWN',
|
|
105
|
+
message: `Workflow ${opts.workflow.toolName} has no requests — nothing to execute.`,
|
|
106
|
+
remediation:
|
|
107
|
+
're-record the session (capture probably stopped before any XHR fired), or re-run `imprint generate` if the workflow JSON looks empty.',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const credentials =
|
|
112
|
+
opts.credentials ??
|
|
113
|
+
(await loadCredentialStore(opts.workflow.site)) ??
|
|
114
|
+
emptyStore(opts.workflow.site);
|
|
115
|
+
|
|
116
|
+
// Validate required parameters are present.
|
|
117
|
+
for (const p of opts.workflow.parameters) {
|
|
118
|
+
if (!(p.name in opts.params) && p.default === undefined) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: 'UNKNOWN',
|
|
122
|
+
message: `Missing required parameter: ${p.name} (${p.description})`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// rawResponses feeds parser modules and the final return shape. responseSlots
|
|
128
|
+
// keeps legacy request.extract aliases without replacing raw parser input.
|
|
129
|
+
const responseSlots: ResponseSlot[] = [];
|
|
130
|
+
const state: Record<string, unknown> = { ...(opts.initialState ?? {}) };
|
|
131
|
+
|
|
132
|
+
// Per-execution mutable jar. Never shared across MCP/cron calls.
|
|
133
|
+
const cookieJar = new RuntimeCookieJar(credentials.cookies);
|
|
134
|
+
const liveCredentials: CredentialStore = { ...credentials, cookies: cookieJar.toJSON() };
|
|
135
|
+
const stateCapabilities = collectStateCapabilities(opts.workflow);
|
|
136
|
+
const dependencyPreflight = preflightStateDependencies(opts.workflow, state, stateCapabilities);
|
|
137
|
+
if (!dependencyPreflight.ok) return dependencyPreflight.result;
|
|
138
|
+
|
|
139
|
+
type TransformResult = string | { url: string; body?: string; headers?: Record<string, string> };
|
|
140
|
+
let requestTransform:
|
|
141
|
+
| ((
|
|
142
|
+
method: string,
|
|
143
|
+
url: string,
|
|
144
|
+
responses: unknown[],
|
|
145
|
+
params?: Record<string, string | number | boolean>,
|
|
146
|
+
) => TransformResult)
|
|
147
|
+
| null = null;
|
|
148
|
+
if (opts.workflow.requestTransformModule && opts.workflowPath) {
|
|
149
|
+
try {
|
|
150
|
+
const transformPath = pathResolve(
|
|
151
|
+
dirname(opts.workflowPath),
|
|
152
|
+
opts.workflow.requestTransformModule,
|
|
153
|
+
);
|
|
154
|
+
const mod = await import(transformPath);
|
|
155
|
+
if (typeof mod.transform === 'function') requestTransform = mod.transform;
|
|
156
|
+
} catch {
|
|
157
|
+
// Non-fatal — proceed without transform.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (let i = 0; i < opts.workflow.requests.length; i++) {
|
|
162
|
+
const req = opts.workflow.requests[i];
|
|
163
|
+
if (!req) continue;
|
|
164
|
+
|
|
165
|
+
const subbedResult = substituteRequest(req, {
|
|
166
|
+
params: opts.params,
|
|
167
|
+
credentials: liveCredentials,
|
|
168
|
+
responseSlots,
|
|
169
|
+
state,
|
|
170
|
+
cookieJar,
|
|
171
|
+
stateCapabilities,
|
|
172
|
+
requestUrlTemplate: req.url,
|
|
173
|
+
});
|
|
174
|
+
if (!subbedResult.ok) return subbedResult.result;
|
|
175
|
+
const subbed = subbedResult.value;
|
|
176
|
+
|
|
177
|
+
if (requestTransform) {
|
|
178
|
+
try {
|
|
179
|
+
const transformResult = requestTransform(
|
|
180
|
+
subbed.method,
|
|
181
|
+
subbed.url,
|
|
182
|
+
responseSlots.map((s) => s.raw),
|
|
183
|
+
opts.params,
|
|
184
|
+
);
|
|
185
|
+
if (typeof transformResult === 'string') {
|
|
186
|
+
subbed.url = transformResult;
|
|
187
|
+
} else if (transformResult && typeof transformResult === 'object') {
|
|
188
|
+
subbed.url = transformResult.url;
|
|
189
|
+
if (transformResult.body !== undefined) subbed.body = transformResult.body;
|
|
190
|
+
if (transformResult.headers) {
|
|
191
|
+
for (const [k, v] of Object.entries(transformResult.headers)) {
|
|
192
|
+
subbed.headers[k] = v;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// Non-fatal — proceed with the original request.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const cookieHeader = cookieJar.getCookieHeader(subbed.url);
|
|
202
|
+
if (cookieHeader && !hasHeader(subbed.headers, 'cookie')) subbed.headers.cookie = cookieHeader;
|
|
203
|
+
|
|
204
|
+
const controller = new AbortController();
|
|
205
|
+
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
|
|
206
|
+
|
|
207
|
+
let resp: Response;
|
|
208
|
+
try {
|
|
209
|
+
resp = await fetchFn(subbed.url, {
|
|
210
|
+
method: subbed.method,
|
|
211
|
+
headers: subbed.headers,
|
|
212
|
+
body: subbed.body,
|
|
213
|
+
signal: controller.signal,
|
|
214
|
+
redirect: 'follow',
|
|
215
|
+
});
|
|
216
|
+
} catch (err) {
|
|
217
|
+
clearTimeout(timeoutHandle);
|
|
218
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
219
|
+
if (msg.includes('aborted') || msg.includes('AbortError')) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error: 'NETWORK',
|
|
223
|
+
message: `Request ${i} timed out after ${timeoutMs}ms`,
|
|
224
|
+
remediation: 'Retry, or increase the timeout if the endpoint is slow.',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return { ok: false, error: 'NETWORK', message: `Request ${i} failed: ${msg}` };
|
|
228
|
+
}
|
|
229
|
+
clearTimeout(timeoutHandle);
|
|
230
|
+
|
|
231
|
+
if (resp.status === 401) {
|
|
232
|
+
const text = await safeText(resp);
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
error: 'AUTH_EXPIRED',
|
|
236
|
+
message: `Request ${i} returned 401 — auth has likely expired: ${text.slice(0, 300)}`,
|
|
237
|
+
remediation: `Run \`imprint login ${opts.workflow.site}\` to refresh credentials.`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (resp.status === 403) {
|
|
241
|
+
// 403 = bot detection / geo / ToS / missing capability. The body
|
|
242
|
+
// usually disambiguates — surface it rather than guessing.
|
|
243
|
+
const text = await safeText(resp);
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
error: 'FORBIDDEN',
|
|
247
|
+
message: `Request ${i} returned 403: ${text.slice(0, 300)}`,
|
|
248
|
+
remediation: `Common causes: bot detection (Akamai/Cloudflare/DataDome), geo-block, expired credential, or ToS violation. Inspect the response body above; if it looks like bot detection, the captured workflow can't replay against this site without a real browser. If it's auth, try \`imprint login ${opts.workflow.site}\`.`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (resp.status === 429) {
|
|
252
|
+
const text = await safeText(resp);
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
error: 'RATE_LIMITED',
|
|
256
|
+
message: `Request ${i} returned 429: ${text.slice(0, 300)}`,
|
|
257
|
+
remediation: 'Back off and retry after the Retry-After interval.',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (resp.status >= 400) {
|
|
261
|
+
const text = await safeText(resp);
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
error: 'BAD_RESPONSE',
|
|
265
|
+
message: `Request ${i} (${subbed.method} ${subbed.url}) returned ${resp.status}: ${text.slice(0, 500)}`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Capture Set-Cookie response headers into the in-flight cookie jar before
|
|
270
|
+
// evaluating captures. Set-Cookie is not exposed as a normal header capture.
|
|
271
|
+
try {
|
|
272
|
+
for (const sc of extractSetCookieHeaders(resp.headers))
|
|
273
|
+
cookieJar.setCookieFromHeader(sc, subbed.url);
|
|
274
|
+
liveCredentials.cookies = cookieJar.toJSON();
|
|
275
|
+
} catch {
|
|
276
|
+
// Non-fatal; cookies stay as they were.
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const text = await safeText(resp);
|
|
280
|
+
let parsed: unknown = text;
|
|
281
|
+
try {
|
|
282
|
+
parsed = JSON.parse(text);
|
|
283
|
+
} catch {
|
|
284
|
+
// Not valid JSON — keep as raw text string.
|
|
285
|
+
}
|
|
286
|
+
const aliases = evaluateLegacyExtract(req, parsed);
|
|
287
|
+
responseSlots.push({ raw: parsed, aliases });
|
|
288
|
+
|
|
289
|
+
const captureResult = evaluateRequestCaptures(req.captures ?? [], {
|
|
290
|
+
parsed,
|
|
291
|
+
text,
|
|
292
|
+
headers: resp.headers,
|
|
293
|
+
requestUrl: subbed.url,
|
|
294
|
+
cookieJar,
|
|
295
|
+
});
|
|
296
|
+
if (!captureResult.ok) return captureResult.result;
|
|
297
|
+
Object.assign(state, captureResult.value);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Apply parser if present
|
|
301
|
+
let finalData = responseSlots.at(-1)?.raw ?? null;
|
|
302
|
+
if (opts.workflow.parserModule && opts.workflowPath) {
|
|
303
|
+
try {
|
|
304
|
+
const parserModulePath = pathResolve(dirname(opts.workflowPath), opts.workflow.parserModule);
|
|
305
|
+
const mod = await import(parserModulePath);
|
|
306
|
+
if (typeof mod.extract !== 'function') {
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
error: 'BAD_RESPONSE',
|
|
310
|
+
message: 'parser module does not export extract function',
|
|
311
|
+
remediation: 'regenerate the workflow via `imprint compile`',
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
finalData = mod.extract(finalData, {
|
|
315
|
+
params: opts.params,
|
|
316
|
+
responses: responseSlots.map((s) => s.raw),
|
|
317
|
+
});
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
error: 'BAD_RESPONSE',
|
|
322
|
+
message: `parser failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
323
|
+
remediation: 'check the parser module or regenerate the workflow',
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Return the LAST response as the workflow's `data`.
|
|
329
|
+
return { ok: true, data: finalData as T };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function emptyStore(site: string): CredentialStore {
|
|
333
|
+
return { site, cookies: [], values: {}, storage: [] };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
interface SubstitutedRequest {
|
|
337
|
+
method: string;
|
|
338
|
+
url: string;
|
|
339
|
+
headers: Record<string, string>;
|
|
340
|
+
body?: string;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
type RuntimeErrorResult = Extract<ToolResult, { ok: false }>;
|
|
344
|
+
type RuntimeResult<T> = { ok: true; value: T } | { ok: false; result: RuntimeErrorResult };
|
|
345
|
+
|
|
346
|
+
interface SubstituteRuntime {
|
|
347
|
+
params: Record<string, string | number | boolean>;
|
|
348
|
+
credentials: CredentialStore;
|
|
349
|
+
responseSlots: ResponseSlot[];
|
|
350
|
+
state: Record<string, unknown>;
|
|
351
|
+
cookieJar: RuntimeCookieJar;
|
|
352
|
+
stateCapabilities: Map<string, StateCapability>;
|
|
353
|
+
requestUrlTemplate: string;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function substituteRequest(
|
|
357
|
+
req: WorkflowRequest,
|
|
358
|
+
runtime: SubstituteRuntime,
|
|
359
|
+
): RuntimeResult<SubstitutedRequest> {
|
|
360
|
+
const urlResult = substituteStringInternal(req.url, runtime, undefined);
|
|
361
|
+
if (!urlResult.ok) return urlResult;
|
|
362
|
+
const subbed: SubstitutedRequest = { method: req.method, url: urlResult.value, headers: {} };
|
|
363
|
+
|
|
364
|
+
const requestRuntime = { ...runtime, requestUrlTemplate: subbed.url };
|
|
365
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
366
|
+
const headerResult = substituteStringInternal(v, requestRuntime, 'header');
|
|
367
|
+
if (!headerResult.ok) return headerResult;
|
|
368
|
+
subbed.headers[k] = headerResult.value;
|
|
369
|
+
}
|
|
370
|
+
if (req.body !== undefined) {
|
|
371
|
+
const ct = (req.headers['content-type'] ?? req.headers['Content-Type'] ?? '').toLowerCase();
|
|
372
|
+
const ctx: SubstitutionContext = ct.includes('json')
|
|
373
|
+
? 'json-body'
|
|
374
|
+
: ct.includes('urlencoded') || req.body.includes('=')
|
|
375
|
+
? 'form-body'
|
|
376
|
+
: 'opaque-body';
|
|
377
|
+
const bodyResult = substituteStringInternal(req.body, requestRuntime, ctx);
|
|
378
|
+
if (!bodyResult.ok) return bodyResult;
|
|
379
|
+
subbed.body = bodyResult.value;
|
|
380
|
+
}
|
|
381
|
+
return { ok: true, value: subbed };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const PLACEHOLDER_RE = /\$\{([^}]+)\}/g;
|
|
385
|
+
|
|
386
|
+
/** What kind of context the template represents; controls how substituted
|
|
387
|
+
* values are escaped. */
|
|
388
|
+
type SubstitutionContext = 'url' | 'form-body' | 'json-body' | 'opaque-body' | 'header';
|
|
389
|
+
|
|
390
|
+
export function substituteString(
|
|
391
|
+
template: string,
|
|
392
|
+
params: Record<string, string | number | boolean>,
|
|
393
|
+
credentials: CredentialStore,
|
|
394
|
+
responses: unknown[],
|
|
395
|
+
context?: SubstitutionContext,
|
|
396
|
+
): string {
|
|
397
|
+
const runtime: SubstituteRuntime = {
|
|
398
|
+
params,
|
|
399
|
+
credentials,
|
|
400
|
+
responseSlots: responses.map((raw) => ({ raw, aliases: {} })),
|
|
401
|
+
state: {},
|
|
402
|
+
cookieJar: new RuntimeCookieJar(credentials.cookies),
|
|
403
|
+
stateCapabilities: new Map(),
|
|
404
|
+
requestUrlTemplate: template,
|
|
405
|
+
};
|
|
406
|
+
const result = substituteStringInternal(template, runtime, context);
|
|
407
|
+
if (!result.ok) throw new Error(result.result.message);
|
|
408
|
+
return result.value;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function substituteStringInternal(
|
|
412
|
+
template: string,
|
|
413
|
+
runtime: SubstituteRuntime,
|
|
414
|
+
context?: SubstitutionContext,
|
|
415
|
+
): RuntimeResult<string> {
|
|
416
|
+
let missing: RuntimeErrorResult | null = null;
|
|
417
|
+
const out = template.replace(PLACEHOLDER_RE, (match, expr: string) => {
|
|
418
|
+
const resolved = resolvePlaceholder(match, expr, template, runtime, context);
|
|
419
|
+
if (!resolved.ok) {
|
|
420
|
+
missing = resolved.result;
|
|
421
|
+
return match;
|
|
422
|
+
}
|
|
423
|
+
return resolved.value;
|
|
424
|
+
});
|
|
425
|
+
return missing ? { ok: false, result: missing } : { ok: true, value: out };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function resolvePlaceholder(
|
|
429
|
+
match: string,
|
|
430
|
+
expr: string,
|
|
431
|
+
template: string,
|
|
432
|
+
runtime: SubstituteRuntime,
|
|
433
|
+
context?: SubstitutionContext,
|
|
434
|
+
): RuntimeResult<string> {
|
|
435
|
+
const parsed = parsePlaceholderExpression(expr);
|
|
436
|
+
if (!parsed) return { ok: true, value: match };
|
|
437
|
+
|
|
438
|
+
if (parsed.kind === 'response') {
|
|
439
|
+
const slot = runtime.responseSlots[parsed.index];
|
|
440
|
+
if (!slot) {
|
|
441
|
+
return missingState({
|
|
442
|
+
name: match,
|
|
443
|
+
source: 'response',
|
|
444
|
+
capability: 'ordinary_http',
|
|
445
|
+
failure: 'producer_unavailable',
|
|
446
|
+
message: `Workflow refers to ${match} but only ${runtime.responseSlots.length} responses exist so far`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
const v =
|
|
450
|
+
parsed.path in slot.aliases ? slot.aliases[parsed.path] : jsonpath(slot.raw, parsed.path);
|
|
451
|
+
return { ok: true, value: encodePart(v, template, match, context) };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (parsed.kind === 'env') {
|
|
455
|
+
const v = process.env[parsed.name];
|
|
456
|
+
if (v === undefined) {
|
|
457
|
+
return missingState({
|
|
458
|
+
name: parsed.name,
|
|
459
|
+
source: 'workflow',
|
|
460
|
+
capability: 'unsupported',
|
|
461
|
+
failure: 'unsupported_workflow',
|
|
462
|
+
message: `Workflow placeholder ${match} but environment variable "${parsed.name}" is not set`,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return { ok: true, value: encodePart(v, template, match, context) };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (parsed.kind === 'param') {
|
|
469
|
+
if (!(parsed.name in runtime.params)) {
|
|
470
|
+
const available = Object.keys(runtime.params);
|
|
471
|
+
const hint =
|
|
472
|
+
available.length === 0
|
|
473
|
+
? `no params were passed; the tool needs --param ${parsed.name}=<value>`
|
|
474
|
+
: `available params: ${available.join(', ')}`;
|
|
475
|
+
return missingState({
|
|
476
|
+
name: parsed.name,
|
|
477
|
+
source: 'workflow',
|
|
478
|
+
capability: 'unsupported',
|
|
479
|
+
failure: 'unsupported_workflow',
|
|
480
|
+
message: `Workflow placeholder ${match} but no param "${parsed.name}" provided (${hint})`,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return { ok: true, value: encodePart(runtime.params[parsed.name], template, match, context) };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (parsed.kind === 'credential') {
|
|
487
|
+
const v = runtime.credentials.values[parsed.name];
|
|
488
|
+
if (v === undefined) {
|
|
489
|
+
return missingState({
|
|
490
|
+
name: parsed.name,
|
|
491
|
+
source: 'credential',
|
|
492
|
+
capability: 'credential_required',
|
|
493
|
+
failure: 'credential_missing',
|
|
494
|
+
message: buildMissingCredentialMessage(runtime.credentials, parsed.name),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return { ok: true, value: encodePart(v, template, match, context) };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (parsed.kind === 'state') {
|
|
501
|
+
if (!(parsed.name in runtime.state)) {
|
|
502
|
+
const capability = runtime.stateCapabilities.get(parsed.name) ?? 'unsupported';
|
|
503
|
+
return missingState({
|
|
504
|
+
name: parsed.name,
|
|
505
|
+
source: 'state',
|
|
506
|
+
capability,
|
|
507
|
+
failure: 'producer_unavailable',
|
|
508
|
+
message: `Workflow placeholder ${match} but state "${parsed.name}" has not been captured yet`,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return { ok: true, value: encodePart(runtime.state[parsed.name], template, match, context) };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const lookup = runtime.cookieJar.lookup(parsed.name, runtime.requestUrlTemplate);
|
|
515
|
+
if (!lookup.ok) {
|
|
516
|
+
return missingState({
|
|
517
|
+
name: parsed.name,
|
|
518
|
+
source: 'cookie',
|
|
519
|
+
capability: 'ordinary_http',
|
|
520
|
+
failure: lookup.reason === 'ambiguous' ? 'ambiguous_cookie' : 'producer_ran_value_absent',
|
|
521
|
+
message:
|
|
522
|
+
lookup.reason === 'ambiguous'
|
|
523
|
+
? `Cookie placeholder ${match} is ambiguous for ${runtime.requestUrlTemplate}; use a named capture with url/domain/path constraints.`
|
|
524
|
+
: lookup.reason === 'httponly'
|
|
525
|
+
? `Cookie placeholder ${match} refers to an HttpOnly cookie; use a named capture with allowHttpOnlyProjection only if intentional.`
|
|
526
|
+
: `Cookie placeholder ${match} could not find cookie "${parsed.name}" for ${runtime.requestUrlTemplate}`,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return { ok: true, value: encodePart(lookup.cookie.value, template, match, context) };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
type ParsedPlaceholder =
|
|
533
|
+
| { kind: 'param' | 'credential' | 'env' | 'state' | 'cookie'; name: string }
|
|
534
|
+
| { kind: 'response'; index: number; path: string };
|
|
535
|
+
|
|
536
|
+
function parsePlaceholderExpression(expr: string): ParsedPlaceholder | null {
|
|
537
|
+
const response = expr.match(/^response\[(\d+)\]\.(.+)$/);
|
|
538
|
+
if (response?.[1] && response[2]) {
|
|
539
|
+
return { kind: 'response', index: Number.parseInt(response[1], 10), path: response[2] };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const bracket = expr.match(/^(state|cookie)\["([^"]+)"\]$/);
|
|
543
|
+
if (bracket?.[1] && bracket[2]) {
|
|
544
|
+
return { kind: bracket[1] as 'state' | 'cookie', name: bracket[2] };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const dotted = expr.match(/^(param|credential|env|state|cookie)\.([A-Za-z0-9_.-]+)$/);
|
|
548
|
+
if (dotted?.[1] && dotted[2]) {
|
|
549
|
+
return {
|
|
550
|
+
kind: dotted[1] as 'param' | 'credential' | 'env' | 'state' | 'cookie',
|
|
551
|
+
name: dotted[2],
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Lookup a dotted JSON path inside a parsed value. Supports nested objects + numeric array indices. */
|
|
559
|
+
function jsonpath(root: unknown, path: string): unknown {
|
|
560
|
+
const parts = path.split('.');
|
|
561
|
+
let cur: unknown = root;
|
|
562
|
+
for (const p of parts) {
|
|
563
|
+
if (cur == null) return undefined;
|
|
564
|
+
if (Array.isArray(cur) && /^\d+$/.test(p)) {
|
|
565
|
+
cur = cur[Number.parseInt(p, 10)];
|
|
566
|
+
} else if (typeof cur === 'object') {
|
|
567
|
+
cur = (cur as Record<string, unknown>)[p];
|
|
568
|
+
} else {
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return cur;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function evaluateLegacyExtract(req: WorkflowRequest, parsed: unknown): Record<string, unknown> {
|
|
576
|
+
const aliases: Record<string, unknown> = {};
|
|
577
|
+
for (const [name, path] of Object.entries(req.extract ?? {})) {
|
|
578
|
+
aliases[name] = jsonpath(parsed, path);
|
|
579
|
+
}
|
|
580
|
+
return aliases;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function collectStateCapabilities(workflow: Workflow): Map<string, StateCapability> {
|
|
584
|
+
const out = new Map<string, StateCapability>();
|
|
585
|
+
for (const c of workflow.bootstrap?.captures ?? []) out.set(c.name, c.capability);
|
|
586
|
+
for (const req of workflow.requests) {
|
|
587
|
+
for (const c of req.captures ?? []) out.set(c.name, c.capability);
|
|
588
|
+
}
|
|
589
|
+
return out;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function preflightStateDependencies(
|
|
593
|
+
workflow: Workflow,
|
|
594
|
+
initialState: Record<string, unknown>,
|
|
595
|
+
stateCapabilities: Map<string, StateCapability>,
|
|
596
|
+
): RuntimeResult<void> {
|
|
597
|
+
if (!workflowHasStateFeatures(workflow)) return { ok: true, value: undefined };
|
|
598
|
+
|
|
599
|
+
const producers = new Map<string, number>();
|
|
600
|
+
for (const c of workflow.bootstrap?.captures ?? []) producers.set(c.name, -1);
|
|
601
|
+
workflow.requests.forEach((req, idx) => {
|
|
602
|
+
for (const c of req.captures ?? []) producers.set(c.name, idx);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
for (let i = 0; i < workflow.requests.length; i++) {
|
|
606
|
+
const req = workflow.requests[i];
|
|
607
|
+
if (!req) continue;
|
|
608
|
+
const missingBeforeRequest = collectStatePlaceholders(req).filter((name) => {
|
|
609
|
+
if (name in initialState) return false;
|
|
610
|
+
const producer = producers.get(name);
|
|
611
|
+
return producer === undefined || producer >= i;
|
|
612
|
+
});
|
|
613
|
+
if (missingBeforeRequest.length === 0) continue;
|
|
614
|
+
const hasPriorUnsafe = workflow.requests.slice(0, i).some((r) => requestEffect(r) === 'unsafe');
|
|
615
|
+
if (!hasPriorUnsafe) continue;
|
|
616
|
+
|
|
617
|
+
const name = missingBeforeRequest[0];
|
|
618
|
+
if (!name) continue;
|
|
619
|
+
const capability = stateCapabilities.get(name) ?? 'unsupported';
|
|
620
|
+
return missingState({
|
|
621
|
+
name,
|
|
622
|
+
source: 'state',
|
|
623
|
+
capability,
|
|
624
|
+
failure: producers.has(name) ? 'producer_unavailable' : 'unsupported_workflow',
|
|
625
|
+
message: `Workflow needs state "${name}" before request ${i + 1}, but an earlier unsafe request would run before that state can be produced.`,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return { ok: true, value: undefined };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function workflowHasStateFeatures(workflow: Workflow): boolean {
|
|
633
|
+
return Boolean(
|
|
634
|
+
workflow.bootstrap || workflow.requests.some((r) => r.effect || (r.captures?.length ?? 0) > 0),
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function requestEffect(req: WorkflowRequest): 'safe' | 'idempotent' | 'unsafe' {
|
|
639
|
+
if (req.effect) return req.effect;
|
|
640
|
+
const method = req.method.toUpperCase();
|
|
641
|
+
return method === 'GET' || method === 'HEAD' ? 'safe' : 'unsafe';
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function collectStatePlaceholders(req: WorkflowRequest): string[] {
|
|
645
|
+
const templates = [req.url, ...Object.values(req.headers), req.body ?? ''];
|
|
646
|
+
const names = new Set<string>();
|
|
647
|
+
for (const template of templates) {
|
|
648
|
+
for (const match of template.matchAll(PLACEHOLDER_RE)) {
|
|
649
|
+
const expr = match[1];
|
|
650
|
+
if (!expr) continue;
|
|
651
|
+
const parsed = parsePlaceholderExpression(expr);
|
|
652
|
+
if (parsed?.kind === 'state') names.add(parsed.name);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return Array.from(names);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function evaluateRequestCaptures(
|
|
659
|
+
captures: RequestCapture[],
|
|
660
|
+
ctx: {
|
|
661
|
+
parsed: unknown;
|
|
662
|
+
text: string;
|
|
663
|
+
headers: Headers;
|
|
664
|
+
requestUrl: string;
|
|
665
|
+
cookieJar: RuntimeCookieJar;
|
|
666
|
+
},
|
|
667
|
+
): RuntimeResult<Record<string, unknown>> {
|
|
668
|
+
const values: Record<string, unknown> = {};
|
|
669
|
+
for (const capture of captures) {
|
|
670
|
+
let value: unknown;
|
|
671
|
+
switch (capture.source) {
|
|
672
|
+
case 'json':
|
|
673
|
+
value = jsonpath(ctx.parsed, capture.path);
|
|
674
|
+
break;
|
|
675
|
+
case 'response_header':
|
|
676
|
+
value = captureHeader(ctx.headers, capture.header, capture.mode);
|
|
677
|
+
break;
|
|
678
|
+
case 'text_regex': {
|
|
679
|
+
const re = new RegExp(capture.pattern);
|
|
680
|
+
const match = ctx.text.match(re);
|
|
681
|
+
value = match?.[capture.group ?? 1];
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
case 'cookie': {
|
|
685
|
+
const constraints: CookieLookupConstraints = {
|
|
686
|
+
url: capture.url,
|
|
687
|
+
domain: capture.domain,
|
|
688
|
+
path: capture.path,
|
|
689
|
+
sameSite: capture.sameSite,
|
|
690
|
+
allowHttpOnlyProjection: capture.allowHttpOnlyProjection,
|
|
691
|
+
};
|
|
692
|
+
const lookup = ctx.cookieJar.lookup(
|
|
693
|
+
capture.cookie,
|
|
694
|
+
capture.url ?? ctx.requestUrl,
|
|
695
|
+
constraints,
|
|
696
|
+
);
|
|
697
|
+
if (!lookup.ok) {
|
|
698
|
+
if (capture.required === false) break;
|
|
699
|
+
return missingState({
|
|
700
|
+
name: capture.name,
|
|
701
|
+
source: 'cookie',
|
|
702
|
+
capability: capture.capability,
|
|
703
|
+
failure:
|
|
704
|
+
lookup.reason === 'ambiguous' ? 'ambiguous_cookie' : 'producer_ran_value_absent',
|
|
705
|
+
message:
|
|
706
|
+
lookup.reason === 'ambiguous'
|
|
707
|
+
? `Cookie capture "${capture.name}" is ambiguous; add url/domain/path constraints.`
|
|
708
|
+
: lookup.reason === 'httponly'
|
|
709
|
+
? `Cookie capture "${capture.name}" targets HttpOnly cookie "${capture.cookie}" without allowHttpOnlyProjection.`
|
|
710
|
+
: `Cookie capture "${capture.name}" did not find cookie "${capture.cookie}".`,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
value = lookup.cookie.value;
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (value === undefined || value === null || value === '') {
|
|
719
|
+
if (capture.required === false) continue;
|
|
720
|
+
return missingState({
|
|
721
|
+
name: capture.name,
|
|
722
|
+
source: capture.source === 'cookie' ? 'cookie' : 'response',
|
|
723
|
+
capability: capture.capability,
|
|
724
|
+
failure: 'producer_ran_value_absent',
|
|
725
|
+
message: `Required capture "${capture.name}" (${capture.source}) did not produce a value.`,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
values[capture.name] = value;
|
|
729
|
+
}
|
|
730
|
+
return { ok: true, value: values };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function captureHeader(
|
|
734
|
+
headers: Headers,
|
|
735
|
+
name: string,
|
|
736
|
+
mode: 'first' | 'last' | 'all' = 'last',
|
|
737
|
+
): string | string[] | undefined {
|
|
738
|
+
if (name.toLowerCase() === 'set-cookie') return undefined;
|
|
739
|
+
const value = headers.get(name);
|
|
740
|
+
if (value === null) return undefined;
|
|
741
|
+
const values = value
|
|
742
|
+
.split(',')
|
|
743
|
+
.map((v) => v.trim())
|
|
744
|
+
.filter(Boolean);
|
|
745
|
+
if (mode === 'all') return values.length ? values : [value];
|
|
746
|
+
if (mode === 'first') return values[0] ?? value;
|
|
747
|
+
return values.at(-1) ?? value;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function missingState(input: {
|
|
751
|
+
name: string;
|
|
752
|
+
source: StateMissingItem['source'];
|
|
753
|
+
capability: StateCapability;
|
|
754
|
+
failure: StateMissingItem['failure'];
|
|
755
|
+
message: string;
|
|
756
|
+
}): RuntimeResult<never> {
|
|
757
|
+
return {
|
|
758
|
+
ok: false,
|
|
759
|
+
result: {
|
|
760
|
+
ok: false,
|
|
761
|
+
error: 'STATE_MISSING',
|
|
762
|
+
message: input.message,
|
|
763
|
+
missing: [
|
|
764
|
+
{
|
|
765
|
+
name: input.name,
|
|
766
|
+
source: input.source,
|
|
767
|
+
capability: input.capability,
|
|
768
|
+
required: true,
|
|
769
|
+
failure: input.failure,
|
|
770
|
+
message: input.message,
|
|
771
|
+
},
|
|
772
|
+
],
|
|
773
|
+
remediation: remediationForCapability(input.capability),
|
|
774
|
+
},
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function remediationForCapability(capability: StateCapability): string {
|
|
779
|
+
switch (capability) {
|
|
780
|
+
case 'browser_bootstrap':
|
|
781
|
+
return 'Run through fetch-bootstrap, or add workflow.bootstrap so Imprint can mint browser state before API replay.';
|
|
782
|
+
case 'stealth_bootstrap':
|
|
783
|
+
return 'Run through stealth-fetch so Imprint can mint bot-defense/browser state before API replay.';
|
|
784
|
+
case 'credential_required':
|
|
785
|
+
return 'Provision credentials with `imprint credential set` or rerun `imprint login`.';
|
|
786
|
+
case 'ordinary_http':
|
|
787
|
+
return 'Check request captures and ordering; an earlier HTTP request was expected to produce this state.';
|
|
788
|
+
case 'unsupported':
|
|
789
|
+
return 'Regenerate or edit workflow.json; the workflow references state that no backend can produce.';
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
794
|
+
const lower = name.toLowerCase();
|
|
795
|
+
return Object.keys(headers).some((k) => k.toLowerCase() === lower);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Decide how a substituted value gets escaped before splicing into the
|
|
800
|
+
* template. Honors an explicit context hint when given (set by
|
|
801
|
+
* substituteRequest based on Content-Type); otherwise falls back to a
|
|
802
|
+
* URL-shaped heuristic for backwards compatibility.
|
|
803
|
+
*/
|
|
804
|
+
function encodePart(
|
|
805
|
+
value: unknown,
|
|
806
|
+
template: string,
|
|
807
|
+
match: string,
|
|
808
|
+
context?: SubstitutionContext,
|
|
809
|
+
): string {
|
|
810
|
+
const s = value === undefined || value === null ? '' : String(value);
|
|
811
|
+
|
|
812
|
+
if (context === 'form-body') {
|
|
813
|
+
// Each substituted value sits between `&` and `=` separators; URL-encode
|
|
814
|
+
// so a value containing `@` / `&` / `=` doesn't corrupt the body shape.
|
|
815
|
+
return encodeURIComponent(s);
|
|
816
|
+
}
|
|
817
|
+
if (context === 'json-body') {
|
|
818
|
+
// We're substituting INTO a string that will be parsed as JSON. The
|
|
819
|
+
// template treats `${credential.X}` as a literal string token, so
|
|
820
|
+
// escape characters that would terminate the surrounding JSON string.
|
|
821
|
+
return s
|
|
822
|
+
.replace(/\\/g, '\\\\')
|
|
823
|
+
.replace(/"/g, '\\"')
|
|
824
|
+
.replace(/\n/g, '\\n')
|
|
825
|
+
.replace(/\r/g, '\\r');
|
|
826
|
+
}
|
|
827
|
+
if (context === 'header' || context === 'opaque-body') {
|
|
828
|
+
return s;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// URL context (default).
|
|
832
|
+
const isUrlContext = context === 'url' || /^https?:\/\//.test(template);
|
|
833
|
+
if (!isUrlContext) return s;
|
|
834
|
+
|
|
835
|
+
// If the placeholder sits in the URL path, encode strictly. If it's in the
|
|
836
|
+
// query string, use encodeURIComponent (which is what most clients do).
|
|
837
|
+
const idx = template.indexOf(match);
|
|
838
|
+
const beforeMatch = template.slice(0, idx);
|
|
839
|
+
const inQuery = beforeMatch.includes('?');
|
|
840
|
+
return inQuery ? encodeURIComponent(s) : encodeURI(s);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function safeText(resp: Response): Promise<string> {
|
|
844
|
+
try {
|
|
845
|
+
return await resp.text();
|
|
846
|
+
} catch {
|
|
847
|
+
return '';
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Build a clear, actionable error when a `${credential.NAME}` placeholder
|
|
852
|
+
* can't be resolved. Reads the per-site manifest (if present) so the
|
|
853
|
+
* message can list ALL missing credentials at once and explain the kinds
|
|
854
|
+
* the user is being asked to provision. */
|
|
855
|
+
function buildMissingCredentialMessage(store: CredentialStore, missingName: string): string {
|
|
856
|
+
const site = store.site;
|
|
857
|
+
const have = new Set(Object.keys(store.values));
|
|
858
|
+
// Pull the manifest so we can list every required credential, not just the
|
|
859
|
+
// one that happened to fire first.
|
|
860
|
+
let manifestEntries: Array<{ name: string; kind: string; description?: string }> = [];
|
|
861
|
+
try {
|
|
862
|
+
const m = readSiteManifest(site);
|
|
863
|
+
if (m && Array.isArray(m.secrets)) manifestEntries = m.secrets;
|
|
864
|
+
} catch {
|
|
865
|
+
/* no manifest — fall back to a simpler hint */
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const missingFromManifest = manifestEntries.filter((e) => !have.has(e.name));
|
|
869
|
+
const missing =
|
|
870
|
+
missingFromManifest.length > 0
|
|
871
|
+
? missingFromManifest.map((e) => e.name)
|
|
872
|
+
: have.has(missingName)
|
|
873
|
+
? [missingName]
|
|
874
|
+
: [missingName];
|
|
875
|
+
|
|
876
|
+
const setCommands = missing.map((n) => ` imprint credential set ${site} ${n}`).join('\n');
|
|
877
|
+
const manifestNote =
|
|
878
|
+
missingFromManifest.length > 1
|
|
879
|
+
? `\nAll ${missingFromManifest.length} credentials this skill needs are missing.`
|
|
880
|
+
: '';
|
|
881
|
+
const manifestKinds =
|
|
882
|
+
missingFromManifest.length > 0
|
|
883
|
+
? `\nThe skill's credentials.manifest.json says it expects:\n${missingFromManifest
|
|
884
|
+
.map((e) => ` • ${e.name} [${e.kind}]${e.description ? ` — ${e.description}` : ''}`)
|
|
885
|
+
.join('\n')}`
|
|
886
|
+
: '';
|
|
887
|
+
|
|
888
|
+
return [
|
|
889
|
+
`Missing credential "${missingName}" for site "${site}". The MCP tool can't run until you provision it.${manifestNote}${manifestKinds}`,
|
|
890
|
+
'',
|
|
891
|
+
'To fix — pick ONE of:',
|
|
892
|
+
'',
|
|
893
|
+
' (1) Set it on this machine (interactive, silent prompt):',
|
|
894
|
+
setCommands,
|
|
895
|
+
'',
|
|
896
|
+
' (2) Import an encrypted bundle exported from a machine where this is already set up:',
|
|
897
|
+
` (on the source machine) imprint credential export ${site} --out ${site}.imprintbundle`,
|
|
898
|
+
` (transfer the bundle file via any channel — it's passphrase-protected)`,
|
|
899
|
+
` (on this machine) imprint credential import ${site} ${site}.imprintbundle`,
|
|
900
|
+
'',
|
|
901
|
+
'See docs/credential-sharing.md for the full sharing workflow.',
|
|
902
|
+
].join('\n');
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Pre-flight result for one site's credential readiness. */
|
|
906
|
+
interface CredentialReadinessReport {
|
|
907
|
+
site: string;
|
|
908
|
+
ok: boolean;
|
|
909
|
+
/** Entries the manifest says this site needs but that aren't in the store. */
|
|
910
|
+
missing: Array<{ name: string; kind: string; description?: string }>;
|
|
911
|
+
/** Human-friendly multi-line message; safe to log as-is. Empty when ok. */
|
|
912
|
+
message: string;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Pre-flight check: read the manifest for a site, compare to what's in the
|
|
916
|
+
* credential store, and report what's missing. Used by `imprint mcp-server`
|
|
917
|
+
* startup and `imprint cron` so users find out ahead of the first tool call
|
|
918
|
+
* rather than mid-workflow. Returns `ok: true` if no manifest exists OR if
|
|
919
|
+
* every manifested credential is present. */
|
|
920
|
+
export async function checkSiteCredentialsReady(site: string): Promise<CredentialReadinessReport> {
|
|
921
|
+
const manifest = readSiteManifest(site);
|
|
922
|
+
if (!manifest || manifest.secrets.length === 0) {
|
|
923
|
+
return { site, ok: true, missing: [], message: '' };
|
|
924
|
+
}
|
|
925
|
+
const store = (await loadCredentialStore(site)) ?? { site, cookies: [], values: {}, storage: [] };
|
|
926
|
+
const have = new Set(Object.keys(store.values));
|
|
927
|
+
const missing = manifest.secrets.filter((s) => !have.has(s.name));
|
|
928
|
+
if (missing.length === 0) return { site, ok: true, missing: [], message: '' };
|
|
929
|
+
|
|
930
|
+
const firstMissing = missing[0];
|
|
931
|
+
if (!firstMissing) return { site, ok: true, missing: [], message: '' };
|
|
932
|
+
return {
|
|
933
|
+
site,
|
|
934
|
+
ok: false,
|
|
935
|
+
missing: missing.map((s) => ({
|
|
936
|
+
name: s.name,
|
|
937
|
+
kind: s.kind,
|
|
938
|
+
description: s.description,
|
|
939
|
+
})),
|
|
940
|
+
message: buildMissingCredentialMessage(store, firstMissing.name),
|
|
941
|
+
};
|
|
942
|
+
}
|