imprint-mcp 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +4 -2
- package/prompts/audit-agent.md +4 -2
- package/prompts/auth-compile-agent.md +149 -158
- package/prompts/build-planning.md +14 -9
- package/prompts/compile-agent.md +46 -23
- package/prompts/live-verifier-agent.md +101 -0
- package/prompts/playbook-compilation.md +8 -1
- package/prompts/tool-candidate-detection.md +9 -29
- package/src/cli.ts +83 -1
- package/src/imprint/audit.ts +219 -20
- package/src/imprint/auth-compile-agent.ts +215 -155
- package/src/imprint/auth-compile-tools.ts +300 -118
- package/src/imprint/auth-verifier.ts +167 -180
- package/src/imprint/backend-cache.ts +18 -11
- package/src/imprint/backend-ladder.ts +225 -177
- package/src/imprint/build-plan.ts +3 -8
- package/src/imprint/cdp-browser-fetch.ts +930 -83
- package/src/imprint/claude-cli-compile.ts +46 -20
- package/src/imprint/codex-cli-compile.ts +436 -139
- package/src/imprint/compile-agent-types.ts +15 -10
- package/src/imprint/compile-agent.ts +88 -28
- package/src/imprint/compile-tools.ts +228 -39
- package/src/imprint/compile-verification.ts +187 -0
- package/src/imprint/compile.ts +71 -2
- package/src/imprint/credential-store.ts +57 -44
- package/src/imprint/emit.ts +3 -1
- package/src/imprint/freeform-redact.ts +27 -6
- package/src/imprint/live-verifier.ts +1665 -0
- package/src/imprint/llm.ts +22 -3
- package/src/imprint/login.ts +107 -25
- package/src/imprint/mcp-compile-server.ts +447 -114
- package/src/imprint/mcp-live-verifier-server.ts +359 -0
- package/src/imprint/mcp-server.ts +116 -35
- package/src/imprint/probe-backends.ts +88 -7
- package/src/imprint/recording-request.ts +36 -0
- package/src/imprint/request-capture.ts +40 -17
- package/src/imprint/runtime.ts +490 -337
- package/src/imprint/teach-state.ts +36 -2
- package/src/imprint/teach.ts +302 -146
- package/src/imprint/tool-candidates.ts +8 -10
- package/src/imprint/tool-loader.ts +12 -4
- package/src/imprint/types.ts +137 -68
package/src/imprint/runtime.ts
CHANGED
|
@@ -14,12 +14,12 @@ import {
|
|
|
14
14
|
} from './cookie-jar.ts';
|
|
15
15
|
import {
|
|
16
16
|
type StorageRecord,
|
|
17
|
+
commitSiteAuthState,
|
|
17
18
|
loadSiteCredentials,
|
|
18
19
|
readSiteManifest,
|
|
19
20
|
saveSiteCookies,
|
|
20
|
-
saveSiteSecret,
|
|
21
21
|
} from './credential-store.ts';
|
|
22
|
-
import { captureHeader, jsonpath } from './request-capture.ts';
|
|
22
|
+
import { captureHeader, captureValueMatches, jsonpath } from './request-capture.ts';
|
|
23
23
|
import type {
|
|
24
24
|
RequestCapture,
|
|
25
25
|
StateCapability,
|
|
@@ -41,6 +41,67 @@ export interface CredentialStore {
|
|
|
41
41
|
storage?: StorageRecord[];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
type NavigationOptions = NonNullable<WorkflowRequest['navigation']>;
|
|
45
|
+
type RequestTransformResult =
|
|
46
|
+
| string
|
|
47
|
+
| {
|
|
48
|
+
url?: string;
|
|
49
|
+
body?: string;
|
|
50
|
+
headers?: Record<string, string>;
|
|
51
|
+
navigation?: Partial<NavigationOptions>;
|
|
52
|
+
skip?: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function mergeNavigationOptions(
|
|
56
|
+
base: WorkflowRequest['navigation'],
|
|
57
|
+
override: Partial<NavigationOptions>,
|
|
58
|
+
): NavigationOptions {
|
|
59
|
+
if (
|
|
60
|
+
override.actions !== undefined &&
|
|
61
|
+
(!Array.isArray(override.actions) ||
|
|
62
|
+
override.actions.length === 0 ||
|
|
63
|
+
override.actions.some(
|
|
64
|
+
(entry) =>
|
|
65
|
+
!entry ||
|
|
66
|
+
entry.action !== 'click' ||
|
|
67
|
+
typeof entry.selector !== 'string' ||
|
|
68
|
+
entry.selector.length === 0,
|
|
69
|
+
))
|
|
70
|
+
) {
|
|
71
|
+
throw new Error('request transform returned invalid navigation click actions');
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
override.resultSelector !== undefined &&
|
|
75
|
+
(typeof override.resultSelector !== 'string' || override.resultSelector.length === 0)
|
|
76
|
+
) {
|
|
77
|
+
throw new Error('request transform returned an invalid navigation result selector');
|
|
78
|
+
}
|
|
79
|
+
return { ...base, ...override } as NavigationOptions;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Browser-only workflow operations supplied by cdp-replay. Keeping this
|
|
83
|
+
* transport generic lets pages mint coupled browser state through their own
|
|
84
|
+
* JavaScript without adding OAuth-provider or frontend-framework logic here. */
|
|
85
|
+
export interface BrowserNavigationTransport {
|
|
86
|
+
navigate(
|
|
87
|
+
url: string,
|
|
88
|
+
options?: {
|
|
89
|
+
method?: string;
|
|
90
|
+
headers?: Record<string, string>;
|
|
91
|
+
body?: string;
|
|
92
|
+
waitUntil?: 'domcontentloaded' | 'load';
|
|
93
|
+
timeoutMs?: number;
|
|
94
|
+
pollIntervalMs?: number;
|
|
95
|
+
urlIncludes?: string;
|
|
96
|
+
selector?: string;
|
|
97
|
+
actions?: Array<{ action: 'click'; selector: string }>;
|
|
98
|
+
resultSelector?: string;
|
|
99
|
+
cookie?: { name: string; domain?: string; path?: string };
|
|
100
|
+
},
|
|
101
|
+
): Promise<Response>;
|
|
102
|
+
snapshotCookies(): Promise<RuntimeCookie[]>;
|
|
103
|
+
}
|
|
104
|
+
|
|
44
105
|
/** Load credentials for a site from the credential manager (OS keychain →
|
|
45
106
|
* encrypted-file fallback → legacy JSON for backwards compat). Returns
|
|
46
107
|
* null only if there's truly nothing recorded; a missing keychain entry
|
|
@@ -85,6 +146,8 @@ interface ExecuteOptions {
|
|
|
85
146
|
credentials?: CredentialStore;
|
|
86
147
|
/** Override global fetch (tests, stealth-fetch). */
|
|
87
148
|
fetchImpl?: typeof fetch;
|
|
149
|
+
/** Top-level document navigation, available only on cdp-replay. */
|
|
150
|
+
browser?: BrowserNavigationTransport;
|
|
88
151
|
/** Per-request timeout in ms. Default 30000. */
|
|
89
152
|
requestTimeoutMs?: number;
|
|
90
153
|
/** Absolute path of workflow.json — required for parserModule resolution. */
|
|
@@ -156,14 +219,13 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
156
219
|
const dependencyPreflight = preflightStateDependencies(opts.workflow, state, stateCapabilities);
|
|
157
220
|
if (!dependencyPreflight.ok) return dependencyPreflight.result;
|
|
158
221
|
|
|
159
|
-
type TransformResult = string | { url: string; body?: string; headers?: Record<string, string> };
|
|
160
222
|
let requestTransform:
|
|
161
223
|
| ((
|
|
162
224
|
method: string,
|
|
163
225
|
url: string,
|
|
164
226
|
responses: unknown[],
|
|
165
227
|
params?: Record<string, string | number | boolean>,
|
|
166
|
-
) =>
|
|
228
|
+
) => RequestTransformResult)
|
|
167
229
|
| null = null;
|
|
168
230
|
if (opts.workflow.requestTransformModule && opts.workflowPath) {
|
|
169
231
|
try {
|
|
@@ -193,6 +255,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
193
255
|
});
|
|
194
256
|
if (!subbedResult.ok) return subbedResult.result;
|
|
195
257
|
const subbed = subbedResult.value;
|
|
258
|
+
let navigation = req.navigation;
|
|
196
259
|
|
|
197
260
|
if (requestTransform) {
|
|
198
261
|
try {
|
|
@@ -205,84 +268,140 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
205
268
|
if (typeof transformResult === 'string') {
|
|
206
269
|
subbed.url = transformResult;
|
|
207
270
|
} else if (transformResult && typeof transformResult === 'object') {
|
|
208
|
-
|
|
271
|
+
if (transformResult.skip === true) continue;
|
|
272
|
+
if (typeof transformResult.url === 'string') subbed.url = transformResult.url;
|
|
209
273
|
if (transformResult.body !== undefined) subbed.body = transformResult.body;
|
|
210
274
|
if (transformResult.headers) {
|
|
211
275
|
for (const [k, v] of Object.entries(transformResult.headers)) {
|
|
212
276
|
subbed.headers[k] = v;
|
|
213
277
|
}
|
|
214
278
|
}
|
|
279
|
+
if (transformResult.navigation) {
|
|
280
|
+
navigation = mergeNavigationOptions(navigation, transformResult.navigation);
|
|
281
|
+
}
|
|
215
282
|
}
|
|
216
|
-
} catch {
|
|
217
|
-
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
error: 'BAD_RESPONSE',
|
|
287
|
+
message: `request transform failed for request ${i}: ${
|
|
288
|
+
err instanceof Error ? err.message : String(err)
|
|
289
|
+
}`,
|
|
290
|
+
};
|
|
218
291
|
}
|
|
219
292
|
}
|
|
220
293
|
|
|
221
294
|
const cookieHeader = cookieJar.getCookieHeader(subbed.url);
|
|
222
295
|
if (cookieHeader && !hasHeader(subbed.headers, 'cookie')) subbed.headers.cookie = cookieHeader;
|
|
223
296
|
|
|
224
|
-
const controller = new AbortController();
|
|
225
|
-
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
|
|
226
|
-
|
|
227
297
|
let resp: Response;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
headers: subbed.headers,
|
|
232
|
-
body: subbed.body,
|
|
233
|
-
signal: controller.signal,
|
|
234
|
-
redirect: 'follow',
|
|
235
|
-
});
|
|
236
|
-
} catch (err) {
|
|
237
|
-
clearTimeout(timeoutHandle);
|
|
238
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
239
|
-
if (msg.includes('aborted') || msg.includes('AbortError')) {
|
|
298
|
+
let responseAbortTimer: ReturnType<typeof setTimeout> | undefined;
|
|
299
|
+
if (req.mode === 'navigate') {
|
|
300
|
+
if (!opts.browser) {
|
|
240
301
|
return {
|
|
241
302
|
ok: false,
|
|
242
|
-
error: '
|
|
243
|
-
message: `Request ${i}
|
|
244
|
-
remediation: 'Retry, or increase the timeout if the endpoint is slow.',
|
|
303
|
+
error: 'BAD_RESPONSE',
|
|
304
|
+
message: `Request ${i} requires top-level browser navigation; retry with cdp-replay.`,
|
|
245
305
|
};
|
|
246
306
|
}
|
|
247
|
-
|
|
307
|
+
try {
|
|
308
|
+
resp = await opts.browser.navigate(subbed.url, {
|
|
309
|
+
...navigation,
|
|
310
|
+
method: subbed.method,
|
|
311
|
+
headers: subbed.headers,
|
|
312
|
+
body: subbed.body,
|
|
313
|
+
});
|
|
314
|
+
for (const cookie of await opts.browser.snapshotCookies()) cookieJar.setCookie(cookie);
|
|
315
|
+
liveCredentials.cookies = cookieJar.toJSON();
|
|
316
|
+
} catch (err) {
|
|
317
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
318
|
+
return { ok: false, error: 'NETWORK', message: `Navigation ${i} failed: ${msg}` };
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
const controller = new AbortController();
|
|
322
|
+
responseAbortTimer = setTimeout(() => controller.abort(), timeoutMs);
|
|
323
|
+
try {
|
|
324
|
+
resp = await fetchFn(subbed.url, {
|
|
325
|
+
method: subbed.method,
|
|
326
|
+
headers: subbed.headers,
|
|
327
|
+
body: subbed.body,
|
|
328
|
+
signal: controller.signal,
|
|
329
|
+
redirect: 'follow',
|
|
330
|
+
});
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (responseAbortTimer) clearTimeout(responseAbortTimer);
|
|
333
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
334
|
+
if (msg.includes('aborted') || msg.includes('AbortError')) {
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
error: 'NETWORK',
|
|
338
|
+
message: `Request ${i} timed out after ${timeoutMs}ms`,
|
|
339
|
+
remediation: 'Retry, or increase the timeout if the endpoint is slow.',
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return { ok: false, error: 'NETWORK', message: `Request ${i} failed: ${msg}` };
|
|
343
|
+
}
|
|
248
344
|
}
|
|
249
|
-
|
|
345
|
+
|
|
346
|
+
let text = '';
|
|
347
|
+
let responseReadError: string | undefined;
|
|
348
|
+
try {
|
|
349
|
+
text = await resp.text();
|
|
350
|
+
} catch (err) {
|
|
351
|
+
responseReadError = err instanceof Error ? err.message : String(err);
|
|
352
|
+
} finally {
|
|
353
|
+
if (responseAbortTimer) clearTimeout(responseAbortTimer);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const bodyPreview = responseReadError
|
|
357
|
+
? `[response body unavailable: ${responseReadError}]`
|
|
358
|
+
: text;
|
|
250
359
|
|
|
251
360
|
if (resp.status === 401) {
|
|
252
|
-
const text = await safeText(resp);
|
|
253
361
|
return {
|
|
254
362
|
ok: false,
|
|
255
363
|
error: 'AUTH_EXPIRED',
|
|
256
|
-
message: `Request ${i} returned 401 — auth has likely expired: ${
|
|
364
|
+
message: `Request ${i} returned 401 — auth has likely expired: ${bodyPreview.slice(0, 300)}`,
|
|
257
365
|
remediation: `Run \`imprint login ${opts.workflow.site}\` to refresh credentials.`,
|
|
258
366
|
};
|
|
259
367
|
}
|
|
260
368
|
if (resp.status === 403) {
|
|
261
369
|
// 403 = bot detection / geo / ToS / missing capability. The body
|
|
262
370
|
// usually disambiguates — surface it rather than guessing.
|
|
263
|
-
const text = await safeText(resp);
|
|
264
371
|
return {
|
|
265
372
|
ok: false,
|
|
266
373
|
error: 'FORBIDDEN',
|
|
267
|
-
message: `Request ${i} returned 403: ${
|
|
374
|
+
message: `Request ${i} returned 403: ${bodyPreview.slice(0, 300)}`,
|
|
268
375
|
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}\`.`,
|
|
269
376
|
};
|
|
270
377
|
}
|
|
271
378
|
if (resp.status === 429) {
|
|
272
|
-
const text = await safeText(resp);
|
|
273
379
|
return {
|
|
274
380
|
ok: false,
|
|
275
381
|
error: 'RATE_LIMITED',
|
|
276
|
-
message: `Request ${i} returned 429: ${
|
|
382
|
+
message: `Request ${i} returned 429: ${bodyPreview.slice(0, 300)}`,
|
|
277
383
|
remediation: 'Back off and retry after the Retry-After interval.',
|
|
278
384
|
};
|
|
279
385
|
}
|
|
280
386
|
if (resp.status >= 400) {
|
|
281
|
-
const text = await safeText(resp);
|
|
282
387
|
return {
|
|
283
388
|
ok: false,
|
|
284
389
|
error: 'BAD_RESPONSE',
|
|
285
|
-
message: `Request ${i} (${subbed.method} ${subbed.url}) returned ${resp.status}: ${
|
|
390
|
+
message: `Request ${i} (${subbed.method} ${subbed.url}) returned ${resp.status}: ${bodyPreview.slice(0, 500)}`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (responseReadError) {
|
|
394
|
+
const timedOut =
|
|
395
|
+
responseReadError.includes('aborted') || responseReadError.includes('AbortError');
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
error: 'NETWORK',
|
|
399
|
+
message: timedOut
|
|
400
|
+
? `Request ${i} timed out after ${timeoutMs}ms`
|
|
401
|
+
: `Response ${i} could not be read: ${responseReadError}`,
|
|
402
|
+
remediation: timedOut
|
|
403
|
+
? 'Retry, or increase the timeout if the endpoint is slow.'
|
|
404
|
+
: undefined,
|
|
286
405
|
};
|
|
287
406
|
}
|
|
288
407
|
|
|
@@ -296,7 +415,6 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
296
415
|
// Non-fatal; cookies stay as they were.
|
|
297
416
|
}
|
|
298
417
|
|
|
299
|
-
const text = await safeText(resp);
|
|
300
418
|
let parsed: unknown = text;
|
|
301
419
|
try {
|
|
302
420
|
parsed = JSON.parse(text);
|
|
@@ -354,349 +472,389 @@ function emptyStore(site: string): CredentialStore {
|
|
|
354
472
|
}
|
|
355
473
|
|
|
356
474
|
async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
357
|
-
const fetchFn = opts.fetchImpl ?? fetch;
|
|
358
|
-
const timeoutMs = opts.requestTimeoutMs ?? 30_000;
|
|
359
|
-
const action = String(opts.params?.action ?? 'initiate');
|
|
360
475
|
const authConfig = opts.workflow.authConfig;
|
|
361
|
-
const
|
|
476
|
+
const params = { ...opts.params };
|
|
477
|
+
for (const parameter of opts.workflow.parameters) {
|
|
478
|
+
if (!(parameter.name in params) && parameter.default !== undefined) {
|
|
479
|
+
params[parameter.name] = parameter.default;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const actionName = String(params.action ?? authConfig?.entry ?? '');
|
|
483
|
+
const action = authConfig?.actions[actionName];
|
|
484
|
+
if (!authConfig || !action) {
|
|
485
|
+
return {
|
|
486
|
+
ok: false,
|
|
487
|
+
error: 'UNKNOWN',
|
|
488
|
+
message: `Unknown auth action ${JSON.stringify(actionName)}. Available actions: ${Object.keys(
|
|
489
|
+
authConfig?.actions ?? {},
|
|
490
|
+
).join(', ')}`,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
362
493
|
|
|
494
|
+
const fetchFn = opts.fetchImpl ?? fetch;
|
|
495
|
+
const timeoutMs = opts.requestTimeoutMs ?? 30_000;
|
|
363
496
|
const credentials =
|
|
364
497
|
opts.credentials ??
|
|
365
498
|
(await loadCredentialStore(opts.workflow.site)) ??
|
|
366
499
|
emptyStore(opts.workflow.site);
|
|
367
|
-
|
|
368
500
|
const cookieJar = new RuntimeCookieJar(credentials.cookies);
|
|
369
501
|
const liveCredentials: CredentialStore = { ...credentials, cookies: cookieJar.toJSON() };
|
|
370
502
|
const responseSlots: ResponseSlot[] = [];
|
|
371
|
-
const
|
|
503
|
+
const initialState: Record<string, unknown> = { ...(opts.initialState ?? {}) };
|
|
504
|
+
const state: Record<string, unknown> = { ...initialState };
|
|
372
505
|
const stateCapabilities = collectStateCapabilities(opts.workflow);
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
506
|
+
const missingParameters = action.parameters.filter((name) => !(name in params));
|
|
507
|
+
if (missingParameters.length > 0) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
error: 'STATE_MISSING',
|
|
511
|
+
message: `Auth action ${JSON.stringify(actionName)} requires: ${missingParameters.join(', ')}.`,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
let requestTransform:
|
|
515
|
+
| ((
|
|
516
|
+
method: string,
|
|
517
|
+
url: string,
|
|
518
|
+
responses: unknown[],
|
|
519
|
+
params?: Record<string, string | number | boolean>,
|
|
520
|
+
) => RequestTransformResult)
|
|
379
521
|
| undefined;
|
|
522
|
+
if (opts.workflow.requestTransformModule && opts.workflowPath) {
|
|
523
|
+
try {
|
|
524
|
+
const mod = await import(
|
|
525
|
+
pathResolve(dirname(opts.workflowPath), opts.workflow.requestTransformModule)
|
|
526
|
+
);
|
|
527
|
+
if (typeof mod.transform === 'function') requestTransform = mod.transform;
|
|
528
|
+
} catch {
|
|
529
|
+
// A missing transform is surfaced by the request that depended on it.
|
|
530
|
+
}
|
|
531
|
+
}
|
|
380
532
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
533
|
+
type ResponseContext = {
|
|
534
|
+
parsed: unknown;
|
|
535
|
+
text: string;
|
|
536
|
+
headers: Headers;
|
|
537
|
+
requestUrl: string;
|
|
538
|
+
};
|
|
539
|
+
let lastResponse: ResponseContext | undefined;
|
|
385
540
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
cookieJar,
|
|
393
|
-
stateCapabilities,
|
|
394
|
-
requestUrlTemplate: req.url,
|
|
395
|
-
});
|
|
396
|
-
if (!subbedResult.ok) return subbedResult.result;
|
|
397
|
-
subbedReq = subbedResult.value;
|
|
541
|
+
const fail = (result: RuntimeErrorResult): RuntimeErrorResult => {
|
|
542
|
+
return {
|
|
543
|
+
...result,
|
|
544
|
+
continuation: Object.keys(initialState).length > 0 ? initialState : undefined,
|
|
545
|
+
};
|
|
546
|
+
};
|
|
398
547
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
548
|
+
const persistenceFailure = (material: string, err: unknown): RuntimeErrorResult =>
|
|
549
|
+
fail({
|
|
550
|
+
ok: false,
|
|
551
|
+
error: 'BAD_RESPONSE',
|
|
552
|
+
message: `Auth action ${JSON.stringify(actionName)} could not persist ${material}: ${
|
|
553
|
+
err instanceof Error ? err.message : String(err)
|
|
554
|
+
}`,
|
|
555
|
+
});
|
|
402
556
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
557
|
+
const persistCookies = async (): Promise<RuntimeErrorResult | undefined> => {
|
|
558
|
+
try {
|
|
559
|
+
await saveSiteCookies(opts.workflow.site, cookieJar.toJSON());
|
|
560
|
+
return undefined;
|
|
561
|
+
} catch (err) {
|
|
562
|
+
return persistenceFailure('session cookies', err);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const executeRequest = async (
|
|
567
|
+
requestIndex: number,
|
|
568
|
+
): Promise<RuntimeResult<ResponseContext | undefined>> => {
|
|
569
|
+
const req = opts.workflow.requests[requestIndex];
|
|
570
|
+
if (!req) {
|
|
571
|
+
return {
|
|
572
|
+
ok: false,
|
|
573
|
+
result: {
|
|
574
|
+
ok: false,
|
|
575
|
+
error: 'BAD_RESPONSE',
|
|
576
|
+
message: `Auth action ${JSON.stringify(actionName)} references missing request ${requestIndex}.`,
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const substituted = substituteRequest(req, {
|
|
582
|
+
params,
|
|
583
|
+
credentials: liveCredentials,
|
|
584
|
+
responseSlots,
|
|
585
|
+
state,
|
|
586
|
+
cookieJar,
|
|
587
|
+
stateCapabilities,
|
|
588
|
+
requestUrlTemplate: req.url,
|
|
589
|
+
});
|
|
590
|
+
if (!substituted.ok) return substituted;
|
|
591
|
+
const request = substituted.value;
|
|
592
|
+
let navigation = req.navigation;
|
|
593
|
+
|
|
594
|
+
if (requestTransform) {
|
|
406
595
|
try {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const text = await safeText(resp);
|
|
423
|
-
// An OPTIONAL request (e.g. a "remember this device" registration that
|
|
424
|
-
// 4xxs when the device is already trusted, or a telemetry beacon) must
|
|
425
|
-
// not abort the flow — log and continue to the next (terminal) request.
|
|
426
|
-
if (req.optional) {
|
|
427
|
-
process.stderr.write(
|
|
428
|
-
`[imprint runtime] optional auth request ${i} (${subbedReq.url}) returned ${resp.status} — skipping, continuing\n`,
|
|
429
|
-
);
|
|
430
|
-
continue;
|
|
596
|
+
const transformed = requestTransform(
|
|
597
|
+
request.method,
|
|
598
|
+
request.url,
|
|
599
|
+
responseSlots.map((slot) => slot.raw),
|
|
600
|
+
params,
|
|
601
|
+
);
|
|
602
|
+
if (typeof transformed === 'string') request.url = transformed;
|
|
603
|
+
else if (transformed) {
|
|
604
|
+
if (transformed.skip) return { ok: true, value: undefined };
|
|
605
|
+
if (transformed.url !== undefined) request.url = transformed.url;
|
|
606
|
+
if (transformed.body !== undefined) request.body = transformed.body;
|
|
607
|
+
if (transformed.headers) Object.assign(request.headers, transformed.headers);
|
|
608
|
+
if (transformed.navigation) {
|
|
609
|
+
navigation = mergeNavigationOptions(navigation, transformed.navigation);
|
|
610
|
+
}
|
|
431
611
|
}
|
|
612
|
+
} catch (err) {
|
|
432
613
|
return {
|
|
433
614
|
ok: false,
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
615
|
+
result: {
|
|
616
|
+
ok: false,
|
|
617
|
+
error: 'BAD_RESPONSE',
|
|
618
|
+
message: `request transform failed for request ${requestIndex}: ${
|
|
619
|
+
err instanceof Error ? err.message : String(err)
|
|
620
|
+
}`,
|
|
621
|
+
},
|
|
441
622
|
};
|
|
442
623
|
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const cookieHeader = cookieJar.getCookieHeader(request.url);
|
|
627
|
+
if (cookieHeader && !hasHeader(request.headers, 'cookie'))
|
|
628
|
+
request.headers.cookie = cookieHeader;
|
|
443
629
|
|
|
630
|
+
let response: Response;
|
|
631
|
+
let responseAbortTimer: ReturnType<typeof setTimeout> | undefined;
|
|
632
|
+
if (req.mode === 'navigate') {
|
|
633
|
+
if (!opts.browser) {
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
result: {
|
|
637
|
+
ok: false,
|
|
638
|
+
error: 'BAD_RESPONSE',
|
|
639
|
+
message: `Request ${requestIndex} requires a browser navigation transport.`,
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
}
|
|
444
643
|
try {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
644
|
+
response = await opts.browser.navigate(request.url, {
|
|
645
|
+
...navigation,
|
|
646
|
+
method: request.method,
|
|
647
|
+
headers: request.headers,
|
|
648
|
+
body: request.body,
|
|
649
|
+
});
|
|
650
|
+
for (const cookie of await opts.browser.snapshotCookies()) cookieJar.setCookie(cookie);
|
|
651
|
+
} catch (err) {
|
|
652
|
+
return {
|
|
653
|
+
ok: false,
|
|
654
|
+
result: {
|
|
655
|
+
ok: false,
|
|
656
|
+
error: 'NETWORK',
|
|
657
|
+
message: `Navigation ${requestIndex} failed: ${
|
|
658
|
+
err instanceof Error ? err.message : String(err)
|
|
659
|
+
}`,
|
|
660
|
+
},
|
|
661
|
+
};
|
|
450
662
|
}
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
if (i < initiateCount) loginResponsePreview = text.slice(0, 500);
|
|
455
|
-
|
|
456
|
-
let parsed: unknown = text;
|
|
663
|
+
} else {
|
|
664
|
+
const controller = new AbortController();
|
|
665
|
+
responseAbortTimer = setTimeout(() => controller.abort(), timeoutMs);
|
|
457
666
|
try {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
667
|
+
response = await fetchFn(request.url, {
|
|
668
|
+
method: request.method,
|
|
669
|
+
headers: request.headers,
|
|
670
|
+
body: request.body,
|
|
671
|
+
signal: controller.signal,
|
|
672
|
+
redirect: 'follow',
|
|
673
|
+
});
|
|
674
|
+
} catch (err) {
|
|
675
|
+
if (responseAbortTimer) clearTimeout(responseAbortTimer);
|
|
676
|
+
return {
|
|
677
|
+
ok: false,
|
|
678
|
+
result: {
|
|
679
|
+
ok: false,
|
|
680
|
+
error: 'NETWORK',
|
|
681
|
+
message: `Auth request ${requestIndex} failed: ${
|
|
682
|
+
err instanceof Error ? err.message : String(err)
|
|
683
|
+
}`,
|
|
684
|
+
},
|
|
685
|
+
};
|
|
461
686
|
}
|
|
462
|
-
// Remember the latest response so sessionCapture can be resolved against
|
|
463
|
-
// the final completion response after the login finishes.
|
|
464
|
-
lastAuthResponseCtx = { parsed, text, headers: resp.headers, requestUrl: subbedReq.url };
|
|
465
|
-
const aliases = evaluateLegacyExtract(req, parsed);
|
|
466
|
-
responseSlots.push({ raw: parsed, aliases });
|
|
467
|
-
|
|
468
|
-
const captureResult = evaluateRequestCaptures(req.captures ?? [], {
|
|
469
|
-
parsed,
|
|
470
|
-
text,
|
|
471
|
-
headers: resp.headers,
|
|
472
|
-
requestUrl: subbedReq.url,
|
|
473
|
-
cookieJar,
|
|
474
|
-
});
|
|
475
|
-
if (!captureResult.ok) return captureResult.result;
|
|
476
|
-
Object.assign(state, captureResult.value);
|
|
477
687
|
}
|
|
478
|
-
return null;
|
|
479
|
-
};
|
|
480
688
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
689
|
+
let text = '';
|
|
690
|
+
let responseReadError: string | undefined;
|
|
691
|
+
try {
|
|
692
|
+
text = await response.text();
|
|
693
|
+
} catch (err) {
|
|
694
|
+
responseReadError = err instanceof Error ? err.message : String(err);
|
|
695
|
+
} finally {
|
|
696
|
+
if (responseAbortTimer) clearTimeout(responseAbortTimer);
|
|
697
|
+
}
|
|
698
|
+
if (response.status >= 400) {
|
|
699
|
+
const bodyPreview = responseReadError
|
|
700
|
+
? `[response body unavailable: ${responseReadError}]`
|
|
701
|
+
: text;
|
|
702
|
+
return {
|
|
703
|
+
ok: false,
|
|
704
|
+
result: {
|
|
705
|
+
ok: false,
|
|
706
|
+
error: response.status === 401 ? 'AUTH_EXPIRED' : 'BAD_RESPONSE',
|
|
707
|
+
message: `Auth request ${requestIndex} (${request.method} ${request.url}) returned ${response.status}: ${bodyPreview.slice(0, 500)}`,
|
|
708
|
+
status: response.status,
|
|
709
|
+
responseBodyPreview: bodyPreview.slice(0, 500),
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
if (responseReadError) {
|
|
714
|
+
return {
|
|
715
|
+
ok: false,
|
|
716
|
+
result: {
|
|
717
|
+
ok: false,
|
|
718
|
+
error: 'NETWORK',
|
|
719
|
+
message: `Auth response ${requestIndex} could not be read: ${responseReadError}`,
|
|
720
|
+
},
|
|
721
|
+
};
|
|
509
722
|
}
|
|
510
|
-
};
|
|
511
723
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
724
|
+
for (const header of extractSetCookieHeaders(response.headers)) {
|
|
725
|
+
cookieJar.setCookieFromHeader(header, request.url);
|
|
726
|
+
}
|
|
727
|
+
liveCredentials.cookies = cookieJar.toJSON();
|
|
515
728
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
729
|
+
let parsed: unknown = text;
|
|
730
|
+
try {
|
|
731
|
+
parsed = JSON.parse(text);
|
|
732
|
+
} catch {
|
|
733
|
+
// Keep the raw body for text captures.
|
|
734
|
+
}
|
|
735
|
+
const context = { parsed, text, headers: response.headers, requestUrl: request.url };
|
|
736
|
+
responseSlots.push({ raw: parsed, aliases: evaluateLegacyExtract(req, parsed) });
|
|
737
|
+
const captures = evaluateRequestCaptures(req.captures ?? [], {
|
|
738
|
+
...context,
|
|
739
|
+
cookieJar,
|
|
740
|
+
});
|
|
741
|
+
if (!captures.ok) {
|
|
527
742
|
return {
|
|
528
743
|
ok: false,
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
authConfig.twoFactorType === 'push'
|
|
535
|
-
? 'Approve the push notification on your device, then call again with action=complete.'
|
|
536
|
-
: 'Enter the code and call again with action=submit_otp, otp_code, and the echoed twoFactorContext.'
|
|
537
|
-
}`,
|
|
744
|
+
result: {
|
|
745
|
+
...captures.result,
|
|
746
|
+
status: response.status,
|
|
747
|
+
responseBodyPreview: text.slice(0, 500),
|
|
748
|
+
},
|
|
538
749
|
};
|
|
539
750
|
}
|
|
751
|
+
Object.assign(state, captures.value);
|
|
752
|
+
lastResponse = context;
|
|
753
|
+
return { ok: true, value: context };
|
|
754
|
+
};
|
|
540
755
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const pollMax = pollOverride ?? authConfig.maxPollAttempts ?? 60;
|
|
554
|
-
const pollInterval = authConfig.pollIntervalMs ?? 3000;
|
|
555
|
-
const pollMethod = authConfig.pollMethod ?? 'POST';
|
|
556
|
-
// Many poll/status endpoints reject an empty body (they need the recorded
|
|
557
|
-
// JSON payload, e.g. `{mfaId,...}`). Substitute the declared pollBody once
|
|
558
|
-
// (state is fixed during the completion phase) against the same runtime as
|
|
559
|
-
// any request, so `${state.X}`/`${credential.X}`/`${param.X}` resolve.
|
|
560
|
-
const pollContentType =
|
|
561
|
-
authConfig.pollBody !== undefined
|
|
562
|
-
? (authConfig.pollContentType ?? 'application/json')
|
|
563
|
-
: undefined;
|
|
564
|
-
let pollBody: string | undefined;
|
|
565
|
-
if (authConfig.pollBody !== undefined) {
|
|
566
|
-
const ctLower = (pollContentType ?? '').toLowerCase();
|
|
567
|
-
const bodyCtx: SubstitutionContext = ctLower.includes('json')
|
|
568
|
-
? 'json-body'
|
|
569
|
-
: ctLower.includes('urlencoded') || authConfig.pollBody.includes('=')
|
|
570
|
-
? 'form-body'
|
|
571
|
-
: 'opaque-body';
|
|
572
|
-
const pollBodyResult = substituteStringInternal(
|
|
573
|
-
authConfig.pollBody,
|
|
574
|
-
{
|
|
575
|
-
params,
|
|
576
|
-
credentials: liveCredentials,
|
|
577
|
-
responseSlots,
|
|
578
|
-
state,
|
|
579
|
-
cookieJar,
|
|
580
|
-
stateCapabilities,
|
|
581
|
-
requestUrlTemplate: authConfig.pollEndpoint,
|
|
582
|
-
},
|
|
583
|
-
bodyCtx,
|
|
584
|
-
);
|
|
585
|
-
if (!pollBodyResult.ok) return pollBodyResult.result;
|
|
586
|
-
pollBody = pollBodyResult.value;
|
|
587
|
-
}
|
|
588
|
-
let approved = false;
|
|
589
|
-
for (let attempt = 0; attempt < pollMax; attempt++) {
|
|
590
|
-
await sleep(pollInterval);
|
|
591
|
-
const cookieHeader = cookieJar.getCookieHeader(authConfig.pollEndpoint);
|
|
592
|
-
const pollHeaders: Record<string, string> = {};
|
|
593
|
-
if (cookieHeader) pollHeaders.cookie = cookieHeader;
|
|
594
|
-
if (pollContentType) pollHeaders['content-type'] = pollContentType;
|
|
595
|
-
// Bound each poll the same way as a normal request: without a timeout a
|
|
596
|
-
// pollEndpoint that accepts the connection but never responds hangs this
|
|
597
|
-
// single fetch forever, so the poll budget never advances and `complete`
|
|
598
|
-
// hangs indefinitely. An abort throws → caught below → next attempt.
|
|
599
|
-
const pollController = new AbortController();
|
|
600
|
-
const pollTimeout = setTimeout(() => pollController.abort(), timeoutMs);
|
|
601
|
-
try {
|
|
602
|
-
const pollResp = await fetchFn(authConfig.pollEndpoint, {
|
|
603
|
-
method: pollMethod,
|
|
604
|
-
headers: pollHeaders,
|
|
605
|
-
body: pollBody,
|
|
606
|
-
signal: pollController.signal,
|
|
607
|
-
});
|
|
608
|
-
if (pollResp.ok) {
|
|
609
|
-
const body = await safeText(pollResp);
|
|
610
|
-
let newSessionCookie = false;
|
|
611
|
-
try {
|
|
612
|
-
for (const sc of extractSetCookieHeaders(pollResp.headers)) {
|
|
613
|
-
cookieJar.setCookieFromHeader(sc, authConfig.pollEndpoint);
|
|
614
|
-
newSessionCookie = true;
|
|
615
|
-
}
|
|
616
|
-
liveCredentials.cookies = cookieJar.toJSON();
|
|
617
|
-
} catch {
|
|
618
|
-
/* non-fatal */
|
|
619
|
-
}
|
|
620
|
-
// Approval is recognized from the recording, not from hardcoded
|
|
621
|
-
// strings: `pollTerminal` is a capture the compile agent grounds in
|
|
622
|
-
// the recorded *approved* poll response (and which is absent on the
|
|
623
|
-
// pending ones). It is "done" once that capture yields a value.
|
|
624
|
-
// Fallback when no terminal was declared: a fresh session Set-Cookie
|
|
625
|
-
// appeared, the universal sign of a completed login.
|
|
626
|
-
if (authConfig.pollTerminal) {
|
|
627
|
-
let parsed: unknown = body;
|
|
628
|
-
try {
|
|
629
|
-
parsed = JSON.parse(body);
|
|
630
|
-
} catch {
|
|
631
|
-
/* keep raw */
|
|
632
|
-
}
|
|
633
|
-
const term = evaluateRequestCaptures([authConfig.pollTerminal], {
|
|
634
|
-
parsed,
|
|
635
|
-
text: body,
|
|
636
|
-
headers: pollResp.headers,
|
|
637
|
-
requestUrl: authConfig.pollEndpoint,
|
|
638
|
-
cookieJar,
|
|
639
|
-
});
|
|
640
|
-
if (term.ok && Object.keys(term.value).length > 0) {
|
|
641
|
-
approved = true;
|
|
642
|
-
break;
|
|
643
|
-
}
|
|
644
|
-
} else if (newSessionCookie) {
|
|
645
|
-
approved = true;
|
|
646
|
-
break;
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
} catch {
|
|
650
|
-
// retry (a network error or a timed-out abort falls through to the
|
|
651
|
-
// next poll attempt rather than failing the whole completion)
|
|
652
|
-
} finally {
|
|
653
|
-
clearTimeout(pollTimeout);
|
|
756
|
+
for (let stepIndex = 0; stepIndex < action.steps.length; stepIndex++) {
|
|
757
|
+
const step = action.steps[stepIndex];
|
|
758
|
+
if (!step) continue;
|
|
759
|
+
let matched = !step.repeat;
|
|
760
|
+
|
|
761
|
+
for (let attempt = 0; attempt < (step.repeat?.maxAttempts ?? 1); attempt++) {
|
|
762
|
+
if (attempt > 0 && step.repeat) await sleep(step.repeat.intervalMs);
|
|
763
|
+
const executed = await executeRequest(step.request);
|
|
764
|
+
if (!executed.ok) {
|
|
765
|
+
if (step.onError === 'continue') {
|
|
766
|
+
matched = true;
|
|
767
|
+
break;
|
|
654
768
|
}
|
|
769
|
+
if (step.onError === 'retry' && step.repeat) continue;
|
|
770
|
+
return fail(executed.result);
|
|
655
771
|
}
|
|
656
|
-
if (!
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
}
|
|
772
|
+
if (!step.repeat) break;
|
|
773
|
+
if (!executed.value) continue;
|
|
774
|
+
|
|
775
|
+
const until = evaluateRequestCaptures(
|
|
776
|
+
[{ ...step.repeat.until, required: false } as RequestCapture],
|
|
777
|
+
{ ...executed.value, cookieJar },
|
|
778
|
+
);
|
|
779
|
+
if (until.ok && Object.hasOwn(until.value, step.repeat.until.name)) {
|
|
780
|
+
Object.assign(state, until.value);
|
|
781
|
+
matched = true;
|
|
782
|
+
break;
|
|
662
783
|
}
|
|
663
784
|
}
|
|
664
785
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
786
|
+
if (!matched) {
|
|
787
|
+
return fail({
|
|
788
|
+
ok: false,
|
|
789
|
+
error: 'BAD_RESPONSE',
|
|
790
|
+
message: `Auth action ${JSON.stringify(actionName)} did not satisfy the declared repeat condition for request ${step.request} after ${step.repeat?.maxAttempts ?? 1} attempts.`,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
671
793
|
}
|
|
672
794
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
795
|
+
const missingEvidence = action.outcome.evidence.filter((name) => !Object.hasOwn(state, name));
|
|
796
|
+
if (missingEvidence.length > 0) {
|
|
797
|
+
return fail({
|
|
798
|
+
ok: false,
|
|
799
|
+
error: 'BAD_RESPONSE',
|
|
800
|
+
message: `Auth action ${JSON.stringify(actionName)} completed without declared evidence: ${missingEvidence.join(', ')}.`,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
676
803
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
804
|
+
if (action.outcome.type === 'pause') {
|
|
805
|
+
const missingCarry = action.outcome.carry.filter((name) => !Object.hasOwn(state, name));
|
|
806
|
+
if (missingCarry.length > 0) {
|
|
807
|
+
return fail({
|
|
808
|
+
ok: false,
|
|
809
|
+
error: 'BAD_RESPONSE',
|
|
810
|
+
message: `Auth action ${JSON.stringify(actionName)} completed without declared carry state: ${missingCarry.join(', ')}.`,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
const continuation = Object.fromEntries(
|
|
814
|
+
action.outcome.carry.map((name) => [name, state[name]]),
|
|
815
|
+
);
|
|
816
|
+
const cookieFailure = await persistCookies();
|
|
817
|
+
if (cookieFailure && !opts.browser) return cookieFailure;
|
|
818
|
+
const persistenceWarning = cookieFailure
|
|
819
|
+
? ` ${cookieFailure.message} Continue in the current browser session.`
|
|
820
|
+
: '';
|
|
821
|
+
return {
|
|
822
|
+
ok: false,
|
|
823
|
+
error: 'ACTION_REQUIRED',
|
|
824
|
+
message: `${action.outcome.message}${persistenceWarning}`,
|
|
825
|
+
nextAction: action.outcome.next,
|
|
826
|
+
continuation,
|
|
827
|
+
responseBodyPreview: lastResponse?.text.slice(0, 500),
|
|
828
|
+
};
|
|
680
829
|
}
|
|
681
830
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
831
|
+
const missingPersisted = authConfig.persist.filter((name) => !Object.hasOwn(state, name));
|
|
832
|
+
if (missingPersisted.length > 0) {
|
|
833
|
+
return fail({
|
|
834
|
+
ok: false,
|
|
835
|
+
error: 'BAD_RESPONSE',
|
|
836
|
+
message: `Auth action ${JSON.stringify(actionName)} completed without declared persisted state: ${missingPersisted.join(', ')}.`,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
const persistedSecrets: Record<string, string> = {};
|
|
840
|
+
for (const name of authConfig.persist) {
|
|
841
|
+
const value = state[name];
|
|
842
|
+
if (value !== undefined && value !== null && String(value) !== '') {
|
|
843
|
+
persistedSecrets[name] = String(value);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
await commitSiteAuthState(opts.workflow.site, cookieJar.toJSON(), persistedSecrets);
|
|
848
|
+
} catch (err) {
|
|
849
|
+
return persistenceFailure('authenticated session state', err);
|
|
850
|
+
}
|
|
851
|
+
return { ok: true, data: { authenticated: true } };
|
|
687
852
|
}
|
|
688
853
|
|
|
689
854
|
function sleep(ms: number): Promise<void> {
|
|
690
855
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
691
856
|
}
|
|
692
857
|
|
|
693
|
-
/** Parse a strictly-positive integer from an env string; undefined otherwise. */
|
|
694
|
-
function parsePositiveInt(value: string | undefined): number | undefined {
|
|
695
|
-
if (value === undefined) return undefined;
|
|
696
|
-
const n = Number.parseInt(value, 10);
|
|
697
|
-
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
858
|
interface SubstitutedRequest {
|
|
701
859
|
method: string;
|
|
702
860
|
url: string;
|
|
@@ -1099,14 +1257,17 @@ function evaluateRequestCaptures(
|
|
|
1099
1257
|
}
|
|
1100
1258
|
}
|
|
1101
1259
|
|
|
1102
|
-
if (value
|
|
1260
|
+
if (!captureValueMatches(value, capture.equals)) {
|
|
1103
1261
|
if (capture.required === false) continue;
|
|
1104
1262
|
return missingState({
|
|
1105
1263
|
name: capture.name,
|
|
1106
1264
|
source: capture.source === 'cookie' ? 'cookie' : 'response',
|
|
1107
1265
|
capability: capture.capability,
|
|
1108
1266
|
failure: 'producer_ran_value_absent',
|
|
1109
|
-
message:
|
|
1267
|
+
message:
|
|
1268
|
+
capture.equals === undefined
|
|
1269
|
+
? `Required capture "${capture.name}" (${capture.source}) did not produce a value.`
|
|
1270
|
+
: `Required capture "${capture.name}" (${capture.source}) did not produce the expected value.`,
|
|
1110
1271
|
});
|
|
1111
1272
|
}
|
|
1112
1273
|
values[capture.name] = value;
|
|
@@ -1207,14 +1368,6 @@ function encodePart(
|
|
|
1207
1368
|
return inQuery ? encodeURIComponent(s) : encodeURI(s);
|
|
1208
1369
|
}
|
|
1209
1370
|
|
|
1210
|
-
async function safeText(resp: Response): Promise<string> {
|
|
1211
|
-
try {
|
|
1212
|
-
return await resp.text();
|
|
1213
|
-
} catch {
|
|
1214
|
-
return '';
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
1371
|
/** Build a clear, actionable error when a `${credential.NAME}` placeholder
|
|
1219
1372
|
* can't be resolved. Reads the per-site manifest (if present) so the
|
|
1220
1373
|
* message can list ALL missing credentials at once and explain the kinds
|