imprint-mcp 0.7.0 → 0.7.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 +66 -2
- package/package.json +1 -1
- package/prompts/auth-compile-agent.md +173 -22
- package/prompts/compile-agent.md +45 -8
- package/prompts/component-recovery.md +40 -0
- package/prompts/master-teach-decision.md +28 -3
- package/prompts/master-teach-focused-planner.md +18 -5
- package/src/cli.ts +26 -18
- package/src/imprint/agent-tool.ts +15 -0
- package/src/imprint/agent.ts +2 -11
- package/src/imprint/audit.ts +35 -13
- package/src/imprint/auth-browser-discovery.ts +276 -0
- package/src/imprint/auth-compile-agent.ts +513 -224
- package/src/imprint/auth-compile-tools.ts +213 -25
- package/src/imprint/auth-discovery-evidence.ts +81 -0
- package/src/imprint/auth-plan.ts +28 -0
- package/src/imprint/auth-verifier.ts +234 -20
- package/src/imprint/authentication-interaction.ts +137 -0
- package/src/imprint/backend-ladder.ts +271 -43
- package/src/imprint/build-plan.ts +2 -22
- package/src/imprint/cdp-browser-fetch.ts +242 -60
- package/src/imprint/chromium.ts +93 -29
- package/src/imprint/claude-cli-compile.ts +2 -0
- package/src/imprint/codex-cli-compile.ts +99 -46
- package/src/imprint/compile-agent-types.ts +9 -5
- package/src/imprint/compile-agent.ts +33 -0
- package/src/imprint/compile-tool-executor.ts +783 -0
- package/src/imprint/compile-tools.ts +42 -9
- package/src/imprint/compile-verification.ts +2 -0
- package/src/imprint/compile.ts +3 -0
- package/src/imprint/compiler-log.ts +16 -0
- package/src/imprint/component-recovery.ts +120 -0
- package/src/imprint/cookie-jar.ts +117 -7
- package/src/imprint/credential-extract.ts +9 -2
- package/src/imprint/credential-lock.ts +147 -0
- package/src/imprint/credential-observation.ts +261 -0
- package/src/imprint/credential-store.ts +284 -44
- package/src/imprint/live-component-recovery.ts +73 -0
- package/src/imprint/llm.ts +4 -3
- package/src/imprint/log.ts +16 -1
- package/src/imprint/login.ts +9 -0
- package/src/imprint/master-teach-agent-contracts.ts +40 -3
- package/src/imprint/master-teach-agents.ts +229 -14
- package/src/imprint/master-teach-checks.ts +8 -1
- package/src/imprint/master-teach-controller.ts +681 -138
- package/src/imprint/master-teach-prompt-projections.ts +2 -0
- package/src/imprint/mcp-cdp-sessions.ts +7 -2
- package/src/imprint/mcp-compile-server.ts +62 -850
- package/src/imprint/mcp-server.ts +667 -91
- package/src/imprint/mcp-session-recovery.ts +167 -0
- package/src/imprint/native-teach-agents.ts +232 -108
- package/src/imprint/runtime.ts +416 -47
- package/src/imprint/stealth-chromium.ts +67 -7
- package/src/imprint/teach-browser-resources.ts +88 -0
- package/src/imprint/teach-stage-progress.ts +38 -0
- package/src/imprint/terminal-user-prompt.ts +42 -0
- package/src/imprint/types.ts +51 -18
- package/src/imprint/user-interaction.ts +836 -0
package/src/imprint/runtime.ts
CHANGED
|
@@ -7,12 +7,18 @@
|
|
|
7
7
|
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
9
|
import { dirname, resolve as pathResolve } from 'node:path';
|
|
10
|
+
import { withAbortSignal } from './concurrency.ts';
|
|
10
11
|
import {
|
|
11
12
|
type CookieLookupConstraints,
|
|
12
13
|
type RuntimeCookie,
|
|
13
14
|
RuntimeCookieJar,
|
|
14
15
|
extractSetCookieHeaders,
|
|
15
16
|
} from './cookie-jar.ts';
|
|
17
|
+
import {
|
|
18
|
+
type DispatchObservation,
|
|
19
|
+
dispatchObservation,
|
|
20
|
+
recordCredentialEvent,
|
|
21
|
+
} from './credential-observation.ts';
|
|
16
22
|
import {
|
|
17
23
|
type StorageRecord,
|
|
18
24
|
commitSiteAuthState,
|
|
@@ -29,6 +35,7 @@ import {
|
|
|
29
35
|
resolveJsonCapture,
|
|
30
36
|
} from './request-capture.ts';
|
|
31
37
|
import type {
|
|
38
|
+
BrowserRequestInit,
|
|
32
39
|
RequestCapture,
|
|
33
40
|
RequestStageFact,
|
|
34
41
|
StateCapability,
|
|
@@ -49,6 +56,13 @@ export interface CredentialStore {
|
|
|
49
56
|
values: Record<string, string>;
|
|
50
57
|
/** Durable browser storage captured by `imprint login`; V1 seeds localStorage only. */
|
|
51
58
|
storage?: StorageRecord[];
|
|
59
|
+
/** Factual source label, not a validity judgment. */
|
|
60
|
+
source?: string;
|
|
61
|
+
/** Version of the durable snapshot read by the host, before environment
|
|
62
|
+
* overlays or browser mutations. Enables conditional cookie writeback. */
|
|
63
|
+
durableRevision?: string;
|
|
64
|
+
/** Commit managed authentication against the snapshot used to start it. */
|
|
65
|
+
authCommitRevision?: string;
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
type NavigationOptions = NonNullable<WorkflowRequest['navigation']>;
|
|
@@ -68,7 +82,7 @@ type RequestTransform = (
|
|
|
68
82
|
url: string,
|
|
69
83
|
responses: unknown[],
|
|
70
84
|
params?: Record<string, string | number | boolean>,
|
|
71
|
-
) => RequestTransformResult
|
|
85
|
+
) => RequestTransformResult | Promise<RequestTransformResult>;
|
|
72
86
|
|
|
73
87
|
function mergeNavigationOptions(
|
|
74
88
|
base: WorkflowRequest['navigation'],
|
|
@@ -133,6 +147,8 @@ function responseProvenanceUrl(
|
|
|
133
147
|
* transport generic lets pages mint coupled browser state through their own
|
|
134
148
|
* JavaScript without adding OAuth-provider or frontend-framework logic here. */
|
|
135
149
|
export interface BrowserNavigationTransport {
|
|
150
|
+
/** Actual browser-owned cookies before this workflow starts. */
|
|
151
|
+
initialCookies?: RuntimeCookie[];
|
|
136
152
|
navigate(
|
|
137
153
|
url: string,
|
|
138
154
|
options?: {
|
|
@@ -150,6 +166,7 @@ export interface BrowserNavigationTransport {
|
|
|
150
166
|
cookie?: { name: string; domain?: string; path?: string };
|
|
151
167
|
},
|
|
152
168
|
): Promise<Response>;
|
|
169
|
+
/** Complete cookie snapshot for this isolated browser context; rejects when unavailable. */
|
|
153
170
|
snapshotCookies(): Promise<RuntimeCookie[]>;
|
|
154
171
|
}
|
|
155
172
|
|
|
@@ -157,13 +174,19 @@ export interface BrowserNavigationTransport {
|
|
|
157
174
|
* encrypted-file fallback → legacy JSON for backwards compat). Returns
|
|
158
175
|
* null only if there's truly nothing recorded; a missing keychain entry
|
|
159
176
|
* with no legacy file still yields an empty store. */
|
|
160
|
-
export async function loadCredentialStore(
|
|
161
|
-
|
|
177
|
+
export async function loadCredentialStore(
|
|
178
|
+
site: string,
|
|
179
|
+
signal?: AbortSignal,
|
|
180
|
+
options?: { includeEmpty?: boolean },
|
|
181
|
+
): Promise<CredentialStore | null> {
|
|
182
|
+
const view = await loadSiteCredentials(site, { signal });
|
|
162
183
|
const store: CredentialStore = {
|
|
163
184
|
site: view.site,
|
|
164
185
|
cookies: view.cookies,
|
|
165
186
|
values: { ...view.values },
|
|
166
187
|
storage: view.storage,
|
|
188
|
+
source: 'durable-store',
|
|
189
|
+
durableRevision: view.revision,
|
|
167
190
|
};
|
|
168
191
|
|
|
169
192
|
const envCreds = process.env.IMPRINT_TEACH_CREDENTIALS;
|
|
@@ -171,6 +194,7 @@ export async function loadCredentialStore(site: string): Promise<CredentialStore
|
|
|
171
194
|
try {
|
|
172
195
|
const parsed = JSON.parse(envCreds) as { site: string; values: Record<string, string> };
|
|
173
196
|
if (parsed.site === site && parsed.values) {
|
|
197
|
+
store.source = 'durable-store+environment';
|
|
174
198
|
for (const [k, v] of Object.entries(parsed.values)) {
|
|
175
199
|
if (!(k in store.values)) store.values[k] = v;
|
|
176
200
|
}
|
|
@@ -181,6 +205,7 @@ export async function loadCredentialStore(site: string): Promise<CredentialStore
|
|
|
181
205
|
}
|
|
182
206
|
|
|
183
207
|
if (
|
|
208
|
+
!options?.includeEmpty &&
|
|
184
209
|
Object.keys(store.values).length === 0 &&
|
|
185
210
|
store.cookies.length === 0 &&
|
|
186
211
|
(store.storage?.length ?? 0) === 0
|
|
@@ -201,6 +226,7 @@ export interface ResponseObservation {
|
|
|
201
226
|
valueType: 'null' | 'array' | 'object' | 'string' | 'number' | 'boolean';
|
|
202
227
|
topLevelKeys?: string[];
|
|
203
228
|
arrayLength?: number;
|
|
229
|
+
dispatch?: DispatchObservation;
|
|
204
230
|
}
|
|
205
231
|
|
|
206
232
|
/** Host-only per-request evidence, emitted before status, capture or parser failures.
|
|
@@ -235,6 +261,11 @@ interface ExecuteOptions {
|
|
|
235
261
|
signal?: AbortSignal;
|
|
236
262
|
/** Disable auth cookie/secret writes during offline request rendering. */
|
|
237
263
|
persistAuthState?: boolean;
|
|
264
|
+
/** Host-only snapshot for isolated action tests; never ordinary tool output. */
|
|
265
|
+
onAuthState?: (credentials: CredentialStore) => void;
|
|
266
|
+
/** Host-only complete runtime cookie jar after a completed data request chain.
|
|
267
|
+
* Includes independent HTTP mutations; never returned as ordinary tool data. */
|
|
268
|
+
onCookieState?: (cookies: RuntimeCookie[]) => void;
|
|
238
269
|
/** Internal observation hook used by teach/debug tooling after every
|
|
239
270
|
* substitution and request transform. Raw values stay inside the host and
|
|
240
271
|
* must be projected to bounded, value-free facts before reaching an agent.
|
|
@@ -353,6 +384,7 @@ function responseObservation(
|
|
|
353
384
|
text: string,
|
|
354
385
|
parsed: unknown,
|
|
355
386
|
credentials: CredentialStore,
|
|
387
|
+
dispatch?: DispatchObservation,
|
|
356
388
|
): ResponseObservation {
|
|
357
389
|
let valueType: ResponseObservation['valueType'];
|
|
358
390
|
if (parsed === null) valueType = 'null';
|
|
@@ -364,6 +396,7 @@ function responseObservation(
|
|
|
364
396
|
const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.slice(0, 200);
|
|
365
397
|
return {
|
|
366
398
|
requestIndex,
|
|
399
|
+
...(dispatch ? { dispatch } : {}),
|
|
367
400
|
status: response.status,
|
|
368
401
|
bodyByteLength: new TextEncoder().encode(text).byteLength,
|
|
369
402
|
redactedBodyPreview: boundedResponsePreview(text, credentials),
|
|
@@ -417,7 +450,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
417
450
|
|
|
418
451
|
const credentials =
|
|
419
452
|
opts.credentials ??
|
|
420
|
-
(await loadCredentialStore(opts.workflow.site)) ??
|
|
453
|
+
(await loadCredentialStore(opts.workflow.site, opts.signal)) ??
|
|
421
454
|
emptyStore(opts.workflow.site);
|
|
422
455
|
|
|
423
456
|
// Validate required parameters are present and merge declared defaults
|
|
@@ -448,6 +481,9 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
448
481
|
|
|
449
482
|
// Per-execution mutable jar. Never shared across MCP/cron calls.
|
|
450
483
|
const cookieJar = new RuntimeCookieJar(credentials.cookies);
|
|
484
|
+
const browserCookieJar = new RuntimeCookieJar(
|
|
485
|
+
opts.browser?.initialCookies ?? credentials.cookies,
|
|
486
|
+
);
|
|
451
487
|
const liveCredentials: CredentialStore = { ...credentials, cookies: cookieJar.toJSON() };
|
|
452
488
|
const stateCapabilities = collectStateCapabilities(opts.workflow);
|
|
453
489
|
const dependencyPreflight = preflightStateDependencies(opts.workflow, state, stateCapabilities);
|
|
@@ -534,7 +570,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
534
570
|
if (requestTransform) {
|
|
535
571
|
const bodyBeforeTransform = subbed.body;
|
|
536
572
|
try {
|
|
537
|
-
const transformResult = requestTransform(
|
|
573
|
+
const transformResult = await requestTransform(
|
|
538
574
|
subbed.method,
|
|
539
575
|
subbed.url,
|
|
540
576
|
responseSlots.map((s) => s.raw),
|
|
@@ -603,6 +639,18 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
603
639
|
);
|
|
604
640
|
}
|
|
605
641
|
|
|
642
|
+
const dispatch = dispatchObservation({
|
|
643
|
+
invocationId: attemptId,
|
|
644
|
+
requestIndex: i,
|
|
645
|
+
material: credentials,
|
|
646
|
+
effectiveMaterial: liveCredentials,
|
|
647
|
+
source: opts.credentials
|
|
648
|
+
? `explicit-override:${credentials.source ?? 'caller'}`
|
|
649
|
+
: (credentials.source ?? 'empty-store'),
|
|
650
|
+
request: subbed,
|
|
651
|
+
browserOwned: !!opts.browser,
|
|
652
|
+
});
|
|
653
|
+
|
|
606
654
|
let resp: Response;
|
|
607
655
|
let responseAbortTimer: ReturnType<typeof setTimeout> | undefined;
|
|
608
656
|
if (req.mode === 'navigate') {
|
|
@@ -624,7 +672,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
624
672
|
headers: subbed.headers,
|
|
625
673
|
body: subbed.body,
|
|
626
674
|
});
|
|
627
|
-
|
|
675
|
+
cookieJar.reconcileBrowserSnapshot(await opts.browser.snapshotCookies(), browserCookieJar);
|
|
628
676
|
liveCredentials.cookies = cookieJar.toJSON();
|
|
629
677
|
} catch (err) {
|
|
630
678
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -647,6 +695,13 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
647
695
|
: controller.signal,
|
|
648
696
|
redirect: 'follow',
|
|
649
697
|
});
|
|
698
|
+
if (opts.browser) {
|
|
699
|
+
cookieJar.reconcileBrowserSnapshot(
|
|
700
|
+
await opts.browser.snapshotCookies(),
|
|
701
|
+
browserCookieJar,
|
|
702
|
+
);
|
|
703
|
+
liveCredentials.cookies = cookieJar.toJSON();
|
|
704
|
+
}
|
|
650
705
|
} catch (err) {
|
|
651
706
|
if (responseAbortTimer) clearTimeout(responseAbortTimer);
|
|
652
707
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -676,6 +731,11 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
676
731
|
);
|
|
677
732
|
}
|
|
678
733
|
}
|
|
734
|
+
recordCredentialEvent('response', {
|
|
735
|
+
invocationId: attemptId,
|
|
736
|
+
requestIndex: i,
|
|
737
|
+
status: resp.status,
|
|
738
|
+
});
|
|
679
739
|
requestStageFacts.push({
|
|
680
740
|
requestIndex: i,
|
|
681
741
|
stage: 'send',
|
|
@@ -723,7 +783,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
723
783
|
// Not valid JSON — keep as raw text string.
|
|
724
784
|
}
|
|
725
785
|
try {
|
|
726
|
-
opts.onResponse?.(responseObservation(i, resp, text, parsed, liveCredentials));
|
|
786
|
+
opts.onResponse?.(responseObservation(i, resp, text, parsed, liveCredentials, dispatch));
|
|
727
787
|
} catch {
|
|
728
788
|
// Diagnostics must never change the workflow's execution result.
|
|
729
789
|
}
|
|
@@ -734,6 +794,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
734
794
|
{
|
|
735
795
|
ok: false,
|
|
736
796
|
error: 'AUTH_EXPIRED',
|
|
797
|
+
status: resp.status,
|
|
737
798
|
message: `Request ${i} returned 401 — auth has likely expired: ${bodyPreview.slice(0, 300)}`,
|
|
738
799
|
remediation: `Run \`imprint login ${opts.workflow.site}\` to refresh credentials.`,
|
|
739
800
|
},
|
|
@@ -747,6 +808,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
747
808
|
{
|
|
748
809
|
ok: false,
|
|
749
810
|
error: 'FORBIDDEN',
|
|
811
|
+
status: resp.status,
|
|
750
812
|
message: `Request ${i} returned 403: ${bodyPreview.slice(0, 300)}`,
|
|
751
813
|
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}\`.`,
|
|
752
814
|
},
|
|
@@ -758,6 +820,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
758
820
|
{
|
|
759
821
|
ok: false,
|
|
760
822
|
error: 'RATE_LIMITED',
|
|
823
|
+
status: resp.status,
|
|
761
824
|
message: `Request ${i} returned 429: ${bodyPreview.slice(0, 300)}`,
|
|
762
825
|
remediation: 'Back off and retry after the Retry-After interval.',
|
|
763
826
|
},
|
|
@@ -769,6 +832,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
769
832
|
{
|
|
770
833
|
ok: false,
|
|
771
834
|
error: 'BAD_RESPONSE',
|
|
835
|
+
status: resp.status,
|
|
772
836
|
message: `Request ${i} (${subbed.method} ${subbed.url}) returned ${resp.status}: ${bodyPreview.slice(0, 500)}`,
|
|
773
837
|
},
|
|
774
838
|
requestStageFacts,
|
|
@@ -797,7 +861,11 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
797
861
|
const capturedResponseUrl = responseProvenanceUrl(subbed.url, navigation, resp);
|
|
798
862
|
try {
|
|
799
863
|
for (const sc of extractSetCookieHeaders(resp.headers))
|
|
800
|
-
cookieJar.setCookieFromHeader(
|
|
864
|
+
cookieJar.setCookieFromHeader(
|
|
865
|
+
sc,
|
|
866
|
+
capturedResponseUrl,
|
|
867
|
+
opts.browser ? browserCookieJar : undefined,
|
|
868
|
+
);
|
|
801
869
|
liveCredentials.cookies = cookieJar.toJSON();
|
|
802
870
|
} catch {
|
|
803
871
|
// Non-fatal; cookies stay as they were.
|
|
@@ -817,6 +885,12 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
817
885
|
Object.assign(state, captureResult.value);
|
|
818
886
|
}
|
|
819
887
|
|
|
888
|
+
try {
|
|
889
|
+
opts.onCookieState?.(cookieJar.toJSON());
|
|
890
|
+
} catch {
|
|
891
|
+
// Observation cannot change the request or parser result.
|
|
892
|
+
}
|
|
893
|
+
|
|
820
894
|
// Apply parser if present
|
|
821
895
|
let finalData = responseSlots.at(-1)?.raw ?? null;
|
|
822
896
|
if (opts.onRawResponses) {
|
|
@@ -891,9 +965,12 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
891
965
|
const timeoutMs = opts.requestTimeoutMs ?? 30_000;
|
|
892
966
|
const credentials =
|
|
893
967
|
opts.credentials ??
|
|
894
|
-
(await loadCredentialStore(opts.workflow.site)) ??
|
|
968
|
+
(await loadCredentialStore(opts.workflow.site, opts.signal)) ??
|
|
895
969
|
emptyStore(opts.workflow.site);
|
|
896
970
|
const cookieJar = new RuntimeCookieJar(credentials.cookies);
|
|
971
|
+
const browserCookieJar = new RuntimeCookieJar(
|
|
972
|
+
opts.browser?.initialCookies ?? credentials.cookies,
|
|
973
|
+
);
|
|
897
974
|
const liveCredentials: CredentialStore = { ...credentials, cookies: cookieJar.toJSON() };
|
|
898
975
|
const responseSlots: ResponseSlot[] = [];
|
|
899
976
|
const initialState: Record<string, unknown> = { ...(opts.initialState ?? {}) };
|
|
@@ -907,12 +984,15 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
907
984
|
message: `Auth action ${JSON.stringify(actionName)} requires: ${missingParameters.join(', ')}.`,
|
|
908
985
|
};
|
|
909
986
|
}
|
|
987
|
+
const authSignal = credentials.authCommitRevision === undefined ? undefined : opts.signal;
|
|
910
988
|
let requestTransform: RequestTransform | undefined;
|
|
911
989
|
if (opts.workflow.requestTransformModule && opts.workflowPath) {
|
|
912
990
|
try {
|
|
913
|
-
const
|
|
914
|
-
|
|
991
|
+
const modulePath = pathResolve(
|
|
992
|
+
dirname(opts.workflowPath),
|
|
993
|
+
opts.workflow.requestTransformModule,
|
|
915
994
|
);
|
|
995
|
+
const mod = await withAbortSignal(() => importModuleFresh(modulePath), authSignal);
|
|
916
996
|
if (typeof mod.transform === 'function') {
|
|
917
997
|
requestTransform = mod.transform as RequestTransform;
|
|
918
998
|
}
|
|
@@ -928,11 +1008,65 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
928
1008
|
requestUrl: string;
|
|
929
1009
|
};
|
|
930
1010
|
let lastResponse: ResponseContext | undefined;
|
|
931
|
-
|
|
1011
|
+
let outcomeResponse: ResponseContext | undefined;
|
|
1012
|
+
|
|
1013
|
+
const stageFacts: RequestStageFact[] = [];
|
|
1014
|
+
let stepIndex = 0;
|
|
1015
|
+
let attemptCount = 0;
|
|
1016
|
+
let currentRequestIndex = 0;
|
|
1017
|
+
let phase: RequestStageFact['stage'] = 'preparation';
|
|
1018
|
+
let transportInvoked = false;
|
|
1019
|
+
let managedUnsafeDispatches = 0;
|
|
1020
|
+
let httpStatus: number | undefined;
|
|
1021
|
+
const recordStage = (
|
|
1022
|
+
stage: RequestStageFact['stage'],
|
|
1023
|
+
outcome: RequestStageFact['outcome'],
|
|
1024
|
+
): void => {
|
|
1025
|
+
phase = stage;
|
|
1026
|
+
stageFacts.push({
|
|
1027
|
+
requestIndex: currentRequestIndex,
|
|
1028
|
+
stepIndex,
|
|
1029
|
+
attempt: attemptCount,
|
|
1030
|
+
stage,
|
|
1031
|
+
outcome,
|
|
1032
|
+
transportInvoked,
|
|
1033
|
+
...(httpStatus === undefined ? {} : { httpStatus }),
|
|
1034
|
+
});
|
|
1035
|
+
if (stageFacts.length > 32) stageFacts.shift();
|
|
1036
|
+
};
|
|
932
1037
|
const fail = (result: RuntimeErrorResult): RuntimeErrorResult => {
|
|
1038
|
+
// A user can correct input after the provider rejects this submission.
|
|
1039
|
+
// Managed MCP still requires consent before sending the corrected request.
|
|
1040
|
+
const canCorrectInput =
|
|
1041
|
+
managedUnsafeDispatches === 1 &&
|
|
1042
|
+
action.parameters.length > 0 &&
|
|
1043
|
+
phase === 'send' &&
|
|
1044
|
+
transportInvoked &&
|
|
1045
|
+
opts.workflow.requests[currentRequestIndex]?.effect === 'unsafe' &&
|
|
1046
|
+
result.error !== 'NETWORK' &&
|
|
1047
|
+
httpStatus !== undefined &&
|
|
1048
|
+
httpStatus >= 400 &&
|
|
1049
|
+
httpStatus < 500;
|
|
933
1050
|
return {
|
|
934
1051
|
...result,
|
|
935
|
-
|
|
1052
|
+
requestStageFacts: [...stageFacts, ...(result.requestStageFacts ?? [])].slice(-32),
|
|
1053
|
+
authExecution: {
|
|
1054
|
+
action: actionName,
|
|
1055
|
+
stepIndex,
|
|
1056
|
+
requestIndex: currentRequestIndex,
|
|
1057
|
+
attempts: attemptCount,
|
|
1058
|
+
phase,
|
|
1059
|
+
transportInvoked,
|
|
1060
|
+
responseReceived: httpStatus !== undefined,
|
|
1061
|
+
...(httpStatus === undefined ? {} : { httpStatus }),
|
|
1062
|
+
...result.authExecution,
|
|
1063
|
+
},
|
|
1064
|
+
continuation:
|
|
1065
|
+
(managedUnsafeDispatches === 0 || canCorrectInput) &&
|
|
1066
|
+
(Object.keys(initialState).length > 0 ||
|
|
1067
|
+
(credentials.authCommitRevision !== undefined && opts.initialState !== undefined))
|
|
1068
|
+
? initialState
|
|
1069
|
+
: undefined,
|
|
936
1070
|
};
|
|
937
1071
|
};
|
|
938
1072
|
|
|
@@ -960,13 +1094,24 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
960
1094
|
}`,
|
|
961
1095
|
});
|
|
962
1096
|
|
|
1097
|
+
const cancellationFailure = (): RuntimeErrorResult =>
|
|
1098
|
+
fail({
|
|
1099
|
+
ok: false,
|
|
1100
|
+
error: 'NETWORK',
|
|
1101
|
+
message: `Auth action ${JSON.stringify(actionName)} was cancelled.`,
|
|
1102
|
+
...(httpStatus === undefined ? {} : { status: httpStatus }),
|
|
1103
|
+
});
|
|
1104
|
+
|
|
963
1105
|
const persistCookies = async (): Promise<RuntimeErrorResult | undefined> => {
|
|
1106
|
+
if (opts.signal?.aborted) return cancellationFailure();
|
|
964
1107
|
if (opts.persistAuthState === false) return undefined;
|
|
965
1108
|
try {
|
|
966
|
-
await saveSiteCookies(opts.workflow.site, cookieJar.toJSON());
|
|
967
|
-
return undefined;
|
|
1109
|
+
await saveSiteCookies(opts.workflow.site, cookieJar.toJSON(), { signal: opts.signal });
|
|
1110
|
+
return opts.signal?.aborted ? cancellationFailure() : undefined;
|
|
968
1111
|
} catch (err) {
|
|
969
|
-
return
|
|
1112
|
+
return opts.signal?.aborted
|
|
1113
|
+
? cancellationFailure()
|
|
1114
|
+
: persistenceFailure('session cookies', err);
|
|
970
1115
|
}
|
|
971
1116
|
};
|
|
972
1117
|
|
|
@@ -1004,21 +1149,33 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1004
1149
|
stateCapabilities,
|
|
1005
1150
|
requestUrlTemplate: req.url,
|
|
1006
1151
|
});
|
|
1007
|
-
if (!substituted.ok)
|
|
1152
|
+
if (!substituted.ok) {
|
|
1153
|
+
recordStage('preparation', 'failed');
|
|
1154
|
+
return substituted;
|
|
1155
|
+
}
|
|
1156
|
+
recordStage('preparation', 'passed');
|
|
1008
1157
|
const request = substituted.value;
|
|
1009
1158
|
let navigation = req.navigation;
|
|
1010
1159
|
|
|
1011
|
-
|
|
1160
|
+
const transform = requestTransform;
|
|
1161
|
+
if (transform) {
|
|
1012
1162
|
try {
|
|
1013
|
-
const transformed =
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1163
|
+
const transformed = await withAbortSignal(
|
|
1164
|
+
async () =>
|
|
1165
|
+
transform(
|
|
1166
|
+
request.method,
|
|
1167
|
+
request.url,
|
|
1168
|
+
responseSlots.map((slot) => slot.raw),
|
|
1169
|
+
params,
|
|
1170
|
+
),
|
|
1171
|
+
authSignal,
|
|
1018
1172
|
);
|
|
1019
1173
|
if (typeof transformed === 'string') request.url = transformed;
|
|
1020
1174
|
else if (transformed) {
|
|
1021
|
-
if (transformed.skip)
|
|
1175
|
+
if (transformed.skip) {
|
|
1176
|
+
recordStage('transform', 'skipped');
|
|
1177
|
+
return { ok: true, value: undefined };
|
|
1178
|
+
}
|
|
1022
1179
|
if (transformed.url !== undefined) request.url = transformed.url;
|
|
1023
1180
|
if (transformed.body !== undefined) request.body = transformed.body;
|
|
1024
1181
|
if (transformed.headers) Object.assign(request.headers, transformed.headers);
|
|
@@ -1026,7 +1183,9 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1026
1183
|
navigation = mergeNavigationOptions(navigation, transformed.navigation);
|
|
1027
1184
|
}
|
|
1028
1185
|
}
|
|
1186
|
+
recordStage('transform', 'passed');
|
|
1029
1187
|
} catch (err) {
|
|
1188
|
+
recordStage('transform', 'failed');
|
|
1030
1189
|
return {
|
|
1031
1190
|
ok: false,
|
|
1032
1191
|
result: {
|
|
@@ -1044,11 +1203,33 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1044
1203
|
if (cookieHeader && !hasHeader(request.headers, 'cookie'))
|
|
1045
1204
|
request.headers.cookie = cookieHeader;
|
|
1046
1205
|
observePreparedRequest(opts, requestIndex, request);
|
|
1206
|
+
if (opts.signal?.aborted) {
|
|
1207
|
+
return {
|
|
1208
|
+
ok: false,
|
|
1209
|
+
result: {
|
|
1210
|
+
ok: false,
|
|
1211
|
+
error: 'NETWORK',
|
|
1212
|
+
message: `Auth action ${JSON.stringify(actionName)} was cancelled before dispatch.`,
|
|
1213
|
+
},
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
const dispatch = dispatchObservation({
|
|
1217
|
+
invocationId: attemptId,
|
|
1218
|
+
requestIndex,
|
|
1219
|
+
material: credentials,
|
|
1220
|
+
effectiveMaterial: liveCredentials,
|
|
1221
|
+
source: opts.credentials
|
|
1222
|
+
? `explicit-override:${credentials.source ?? 'caller'}`
|
|
1223
|
+
: (credentials.source ?? 'empty-store'),
|
|
1224
|
+
request,
|
|
1225
|
+
browserOwned: !!opts.browser,
|
|
1226
|
+
});
|
|
1047
1227
|
|
|
1048
1228
|
let response: Response;
|
|
1049
1229
|
let responseAbortTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1050
1230
|
if (req.mode === 'navigate') {
|
|
1051
1231
|
if (!opts.browser) {
|
|
1232
|
+
recordStage('send', 'unavailable');
|
|
1052
1233
|
return {
|
|
1053
1234
|
ok: false,
|
|
1054
1235
|
result: {
|
|
@@ -1059,14 +1240,18 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1059
1240
|
};
|
|
1060
1241
|
}
|
|
1061
1242
|
try {
|
|
1243
|
+
transportInvoked = true;
|
|
1244
|
+
phase = 'send';
|
|
1062
1245
|
response = await opts.browser.navigate(request.url, {
|
|
1063
1246
|
...navigation,
|
|
1064
1247
|
method: request.method,
|
|
1065
1248
|
headers: request.headers,
|
|
1066
1249
|
body: request.body,
|
|
1067
1250
|
});
|
|
1068
|
-
|
|
1251
|
+
httpStatus = response.status;
|
|
1252
|
+
cookieJar.reconcileBrowserSnapshot(await opts.browser.snapshotCookies(), browserCookieJar);
|
|
1069
1253
|
} catch (err) {
|
|
1254
|
+
recordStage('send', 'failed');
|
|
1070
1255
|
return {
|
|
1071
1256
|
ok: false,
|
|
1072
1257
|
result: {
|
|
@@ -1080,19 +1265,33 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1080
1265
|
}
|
|
1081
1266
|
} else {
|
|
1082
1267
|
const controller = new AbortController();
|
|
1083
|
-
const cancel = (): void => controller.abort();
|
|
1084
|
-
opts.signal?.addEventListener('abort', cancel, { once: true });
|
|
1085
1268
|
responseAbortTimer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1086
1269
|
try {
|
|
1087
|
-
|
|
1270
|
+
transportInvoked = true;
|
|
1271
|
+
phase = 'send';
|
|
1272
|
+
const managedUnsafe =
|
|
1273
|
+
credentials.authCommitRevision !== undefined && req.effect === 'unsafe';
|
|
1274
|
+
if (managedUnsafe) managedUnsafeDispatches++;
|
|
1275
|
+
const init: BrowserRequestInit = {
|
|
1088
1276
|
method: request.method,
|
|
1089
1277
|
headers: request.headers,
|
|
1090
1278
|
body: request.body,
|
|
1091
|
-
signal:
|
|
1279
|
+
signal: opts.signal
|
|
1280
|
+
? AbortSignal.any([opts.signal, controller.signal])
|
|
1281
|
+
: controller.signal,
|
|
1092
1282
|
redirect: 'follow',
|
|
1093
|
-
|
|
1283
|
+
...(managedUnsafe ? { allowAmbiguousRetry: false } : {}),
|
|
1284
|
+
};
|
|
1285
|
+
response = await fetchFn(request.url, init);
|
|
1286
|
+
httpStatus = response.status;
|
|
1287
|
+
if (opts.browser)
|
|
1288
|
+
cookieJar.reconcileBrowserSnapshot(
|
|
1289
|
+
await opts.browser.snapshotCookies(),
|
|
1290
|
+
browserCookieJar,
|
|
1291
|
+
);
|
|
1094
1292
|
} catch (err) {
|
|
1095
1293
|
if (responseAbortTimer) clearTimeout(responseAbortTimer);
|
|
1294
|
+
recordStage('send', 'failed');
|
|
1096
1295
|
return {
|
|
1097
1296
|
ok: false,
|
|
1098
1297
|
result: {
|
|
@@ -1103,11 +1302,16 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1103
1302
|
}`,
|
|
1104
1303
|
},
|
|
1105
1304
|
};
|
|
1106
|
-
} finally {
|
|
1107
|
-
opts.signal?.removeEventListener('abort', cancel);
|
|
1108
1305
|
}
|
|
1109
1306
|
}
|
|
1110
1307
|
|
|
1308
|
+
httpStatus = response.status;
|
|
1309
|
+
recordCredentialEvent('response', {
|
|
1310
|
+
invocationId: attemptId,
|
|
1311
|
+
requestIndex,
|
|
1312
|
+
status: response.status,
|
|
1313
|
+
});
|
|
1314
|
+
recordStage('send', 'passed');
|
|
1111
1315
|
let text = '';
|
|
1112
1316
|
let responseReadError: string | undefined;
|
|
1113
1317
|
try {
|
|
@@ -1117,6 +1321,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1117
1321
|
} finally {
|
|
1118
1322
|
if (responseAbortTimer) clearTimeout(responseAbortTimer);
|
|
1119
1323
|
}
|
|
1324
|
+
if (opts.signal?.aborted) return { ok: false, result: cancellationFailure() };
|
|
1120
1325
|
observeResponseEvidence(
|
|
1121
1326
|
opts,
|
|
1122
1327
|
attemptId,
|
|
@@ -1127,6 +1332,21 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1127
1332
|
responseReadError,
|
|
1128
1333
|
liveCredentials,
|
|
1129
1334
|
);
|
|
1335
|
+
if (!responseReadError) {
|
|
1336
|
+
let observed: unknown = text;
|
|
1337
|
+
try {
|
|
1338
|
+
observed = JSON.parse(text);
|
|
1339
|
+
} catch {
|
|
1340
|
+
/* Text is a valid observation. */
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
opts.onResponse?.(
|
|
1344
|
+
responseObservation(requestIndex, response, text, observed, liveCredentials, dispatch),
|
|
1345
|
+
);
|
|
1346
|
+
} catch {
|
|
1347
|
+
/* Diagnostics cannot alter action execution. */
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1130
1350
|
if (response.status >= 400) {
|
|
1131
1351
|
const bodyPreview = responseReadError
|
|
1132
1352
|
? `[response body unavailable: ${responseReadError}]`
|
|
@@ -1155,7 +1375,11 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1155
1375
|
|
|
1156
1376
|
const capturedResponseUrl = responseProvenanceUrl(request.url, navigation, response);
|
|
1157
1377
|
for (const header of extractSetCookieHeaders(response.headers)) {
|
|
1158
|
-
cookieJar.setCookieFromHeader(
|
|
1378
|
+
cookieJar.setCookieFromHeader(
|
|
1379
|
+
header,
|
|
1380
|
+
capturedResponseUrl,
|
|
1381
|
+
opts.browser ? browserCookieJar : undefined,
|
|
1382
|
+
);
|
|
1159
1383
|
}
|
|
1160
1384
|
liveCredentials.cookies = cookieJar.toJSON();
|
|
1161
1385
|
|
|
@@ -1167,11 +1391,13 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1167
1391
|
}
|
|
1168
1392
|
const context = { parsed, text, headers: response.headers, requestUrl: capturedResponseUrl };
|
|
1169
1393
|
responseSlots.push({ raw: parsed, aliases: evaluateLegacyExtract(req, parsed) });
|
|
1394
|
+
for (const capture of req.captures ?? []) delete state[capture.name];
|
|
1170
1395
|
const captures = evaluateRequestCaptures(req.captures ?? [], {
|
|
1171
1396
|
...context,
|
|
1172
1397
|
cookieJar,
|
|
1173
1398
|
});
|
|
1174
1399
|
if (!captures.ok) {
|
|
1400
|
+
recordStage('capture', 'failed');
|
|
1175
1401
|
return {
|
|
1176
1402
|
ok: false,
|
|
1177
1403
|
result: {
|
|
@@ -1181,15 +1407,17 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1181
1407
|
},
|
|
1182
1408
|
};
|
|
1183
1409
|
}
|
|
1410
|
+
recordStage('capture', 'passed');
|
|
1184
1411
|
Object.assign(state, captures.value);
|
|
1185
1412
|
lastResponse = context;
|
|
1186
1413
|
return { ok: true, value: context };
|
|
1187
1414
|
};
|
|
1188
1415
|
|
|
1189
|
-
for (
|
|
1416
|
+
for (stepIndex = 0; stepIndex < action.steps.length; stepIndex++) {
|
|
1190
1417
|
const step = action.steps[stepIndex];
|
|
1191
1418
|
if (!step) continue;
|
|
1192
1419
|
let matched = !step.repeat;
|
|
1420
|
+
let lastFailure: RuntimeErrorResult | undefined;
|
|
1193
1421
|
|
|
1194
1422
|
for (let attempt = 0; attempt < (step.repeat?.maxAttempts ?? 1); attempt++) {
|
|
1195
1423
|
if (attempt > 0 && step.repeat) await sleep(step.repeat.intervalMs, opts.signal);
|
|
@@ -1200,8 +1428,30 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1200
1428
|
message: `Auth action ${JSON.stringify(actionName)} was cancelled.`,
|
|
1201
1429
|
});
|
|
1202
1430
|
}
|
|
1431
|
+
attemptCount = attempt + 1;
|
|
1432
|
+
currentRequestIndex = step.request;
|
|
1433
|
+
phase = 'preparation';
|
|
1434
|
+
transportInvoked = false;
|
|
1435
|
+
httpStatus = undefined;
|
|
1203
1436
|
const executed = await executeRequest(step.request);
|
|
1437
|
+
// A failed or skipped final step cannot branch on an earlier response.
|
|
1438
|
+
outcomeResponse = executed.ok ? executed.value : undefined;
|
|
1439
|
+
if (!outcomeResponse) {
|
|
1440
|
+
for (const capture of opts.workflow.requests[step.request]?.captures ?? []) {
|
|
1441
|
+
delete state[capture.name];
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
if (opts.signal?.aborted) return cancellationFailure();
|
|
1204
1445
|
if (!executed.ok) {
|
|
1446
|
+
lastFailure = executed.result;
|
|
1447
|
+
if (
|
|
1448
|
+
managedUnsafeDispatches > 0 &&
|
|
1449
|
+
lastFailure.error === 'NETWORK' &&
|
|
1450
|
+
transportInvoked &&
|
|
1451
|
+
opts.workflow.requests[step.request]?.effect === 'unsafe'
|
|
1452
|
+
) {
|
|
1453
|
+
return fail(lastFailure);
|
|
1454
|
+
}
|
|
1205
1455
|
if (step.onError === 'continue') {
|
|
1206
1456
|
matched = true;
|
|
1207
1457
|
break;
|
|
@@ -1209,6 +1459,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1209
1459
|
if (step.onError === 'retry' && step.repeat) continue;
|
|
1210
1460
|
return fail(executed.result);
|
|
1211
1461
|
}
|
|
1462
|
+
lastFailure = undefined;
|
|
1212
1463
|
if (!step.repeat) break;
|
|
1213
1464
|
if (!executed.value) continue;
|
|
1214
1465
|
|
|
@@ -1219,20 +1470,59 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1219
1470
|
if (until.ok && Object.hasOwn(until.value, step.repeat.until.name)) {
|
|
1220
1471
|
Object.assign(state, until.value);
|
|
1221
1472
|
matched = true;
|
|
1473
|
+
recordStage('repeat', 'passed');
|
|
1222
1474
|
break;
|
|
1223
1475
|
}
|
|
1476
|
+
recordStage('repeat', 'failed');
|
|
1224
1477
|
}
|
|
1225
1478
|
|
|
1226
1479
|
if (!matched) {
|
|
1480
|
+
return fail({
|
|
1481
|
+
...(lastFailure ?? { ok: false as const, error: 'BAD_RESPONSE' as const }),
|
|
1482
|
+
message: `Auth action ${JSON.stringify(actionName)} did not satisfy the declared repeat condition for request ${step.request} after ${attemptCount} attempts.${lastFailure ? ` Last failure: ${lastFailure.message}` : ''}`,
|
|
1483
|
+
authExecution: {
|
|
1484
|
+
action: actionName,
|
|
1485
|
+
stepIndex,
|
|
1486
|
+
requestIndex: step.request,
|
|
1487
|
+
attempts: attemptCount,
|
|
1488
|
+
phase,
|
|
1489
|
+
transportInvoked,
|
|
1490
|
+
responseReceived: httpStatus !== undefined,
|
|
1491
|
+
...(httpStatus === undefined ? {} : { httpStatus }),
|
|
1492
|
+
...(lastFailure
|
|
1493
|
+
? { lastFailure: { error: lastFailure.error, message: lastFailure.message } }
|
|
1494
|
+
: {}),
|
|
1495
|
+
},
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (opts.signal?.aborted) return cancellationFailure();
|
|
1501
|
+
phase = 'outcome';
|
|
1502
|
+
let outcome = action.outcome;
|
|
1503
|
+
for (const { when } of action.branches ?? []) delete state[when.name];
|
|
1504
|
+
for (const branch of action.branches ?? []) {
|
|
1505
|
+
if (!outcomeResponse) break;
|
|
1506
|
+
let matched: RuntimeResult<Record<string, unknown>>;
|
|
1507
|
+
try {
|
|
1508
|
+
matched = evaluateRequestCaptures([{ ...branch.when, required: false } as RequestCapture], {
|
|
1509
|
+
...outcomeResponse,
|
|
1510
|
+
cookieJar,
|
|
1511
|
+
});
|
|
1512
|
+
} catch (error) {
|
|
1227
1513
|
return fail({
|
|
1228
1514
|
ok: false,
|
|
1229
1515
|
error: 'BAD_RESPONSE',
|
|
1230
|
-
message: `Auth action ${JSON.stringify(actionName)}
|
|
1516
|
+
message: `Auth action ${JSON.stringify(actionName)} could not evaluate branch ${JSON.stringify(branch.when.name)}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1231
1517
|
});
|
|
1232
1518
|
}
|
|
1519
|
+
if (matched.ok && Object.hasOwn(matched.value, branch.when.name)) {
|
|
1520
|
+
Object.assign(state, matched.value);
|
|
1521
|
+
outcome = branch.outcome;
|
|
1522
|
+
break;
|
|
1523
|
+
}
|
|
1233
1524
|
}
|
|
1234
|
-
|
|
1235
|
-
const missingEvidence = action.outcome.evidence.filter((name) => !Object.hasOwn(state, name));
|
|
1525
|
+
const missingEvidence = outcome.evidence.filter((name) => !Object.hasOwn(state, name));
|
|
1236
1526
|
if (missingEvidence.length > 0) {
|
|
1237
1527
|
return fail({
|
|
1238
1528
|
ok: false,
|
|
@@ -1241,8 +1531,8 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1241
1531
|
});
|
|
1242
1532
|
}
|
|
1243
1533
|
|
|
1244
|
-
if (
|
|
1245
|
-
const missingCarry =
|
|
1534
|
+
if (outcome.type === 'pause') {
|
|
1535
|
+
const missingCarry = outcome.carry.filter((name) => !Object.hasOwn(state, name));
|
|
1246
1536
|
if (missingCarry.length > 0) {
|
|
1247
1537
|
return fail({
|
|
1248
1538
|
ok: false,
|
|
@@ -1250,19 +1540,22 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1250
1540
|
message: `Auth action ${JSON.stringify(actionName)} completed without declared carry state: ${missingCarry.join(', ')}.`,
|
|
1251
1541
|
});
|
|
1252
1542
|
}
|
|
1253
|
-
const continuation = Object.fromEntries(
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1543
|
+
const continuation = Object.fromEntries(outcome.carry.map((name) => [name, state[name]]));
|
|
1544
|
+
if (opts.signal?.aborted) return cancellationFailure();
|
|
1545
|
+
const cookieFailure =
|
|
1546
|
+
credentials.authCommitRevision === undefined ? await persistCookies() : undefined;
|
|
1547
|
+
if (opts.signal?.aborted) return cancellationFailure();
|
|
1257
1548
|
if (cookieFailure && !opts.browser) return cookieFailure;
|
|
1258
1549
|
const persistenceWarning = cookieFailure
|
|
1259
1550
|
? ` ${cookieFailure.message} Continue in the current browser session.`
|
|
1260
1551
|
: '';
|
|
1552
|
+
observeAuthState(opts, liveCredentials);
|
|
1261
1553
|
return {
|
|
1262
1554
|
ok: false,
|
|
1263
1555
|
error: 'ACTION_REQUIRED',
|
|
1264
|
-
message: `${
|
|
1265
|
-
nextAction:
|
|
1556
|
+
message: `${outcome.message}${persistenceWarning}`,
|
|
1557
|
+
nextAction: outcome.next,
|
|
1558
|
+
...(outcome.resume ? { resume: outcome.resume } : {}),
|
|
1266
1559
|
continuation,
|
|
1267
1560
|
responseBodyPreview: lastResponse?.text.slice(0, 500),
|
|
1268
1561
|
};
|
|
@@ -1285,16 +1578,86 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
1285
1578
|
persistedSecrets[name] = String(value);
|
|
1286
1579
|
}
|
|
1287
1580
|
}
|
|
1581
|
+
if (opts.signal?.aborted) return cancellationFailure();
|
|
1288
1582
|
if (opts.persistAuthState !== false) {
|
|
1289
1583
|
try {
|
|
1290
|
-
await commitSiteAuthState(opts.workflow.site, cookieJar.toJSON(), persistedSecrets
|
|
1584
|
+
await commitSiteAuthState(opts.workflow.site, cookieJar.toJSON(), persistedSecrets, {
|
|
1585
|
+
signal: opts.signal,
|
|
1586
|
+
expectedRevision: credentials.authCommitRevision,
|
|
1587
|
+
});
|
|
1291
1588
|
} catch (err) {
|
|
1292
|
-
return
|
|
1589
|
+
return opts.signal?.aborted
|
|
1590
|
+
? cancellationFailure()
|
|
1591
|
+
: persistenceFailure('authenticated session state', err);
|
|
1293
1592
|
}
|
|
1294
1593
|
}
|
|
1594
|
+
if (opts.signal?.aborted) return cancellationFailure();
|
|
1595
|
+
observeAuthState(opts, {
|
|
1596
|
+
...liveCredentials,
|
|
1597
|
+
values: { ...liveCredentials.values, ...persistedSecrets },
|
|
1598
|
+
});
|
|
1295
1599
|
return { ok: true, data: { authenticated: true } };
|
|
1296
1600
|
}
|
|
1297
1601
|
|
|
1602
|
+
function observeAuthState(opts: ExecuteOptions, credentials: CredentialStore): void {
|
|
1603
|
+
try {
|
|
1604
|
+
opts.onAuthState?.(structuredClone(credentials));
|
|
1605
|
+
} catch {
|
|
1606
|
+
// Observation cannot change execution or persistence.
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/** Offline synthetic fixture runner using the real action executor. Every call
|
|
1611
|
+
* starts with empty response history, keeps only declared continuation and
|
|
1612
|
+
* session mutations, and disables durable writes. It never contacts the network
|
|
1613
|
+
* unless the fixture's explicit transport does so. */
|
|
1614
|
+
export function createAuthActionTestHarness(input: {
|
|
1615
|
+
workflow: Workflow;
|
|
1616
|
+
workflowPath?: string;
|
|
1617
|
+
credentials?: CredentialStore;
|
|
1618
|
+
respond: (
|
|
1619
|
+
request: PreparedRequestObservation & { action: string },
|
|
1620
|
+
) => Response | Promise<Response>;
|
|
1621
|
+
browser?: BrowserNavigationTransport;
|
|
1622
|
+
}): {
|
|
1623
|
+
run: (
|
|
1624
|
+
action: string,
|
|
1625
|
+
parameters?: Record<string, string | number | boolean>,
|
|
1626
|
+
) => Promise<ToolResult>;
|
|
1627
|
+
} {
|
|
1628
|
+
if (input.workflow.toolKind !== 'authenticate')
|
|
1629
|
+
throw new Error('An authenticate workflow is required.');
|
|
1630
|
+
let credentials = structuredClone(input.credentials ?? emptyStore(input.workflow.site));
|
|
1631
|
+
let continuation: Record<string, unknown> | undefined;
|
|
1632
|
+
return {
|
|
1633
|
+
async run(action, parameters = {}) {
|
|
1634
|
+
let prepared: PreparedRequestObservation | undefined;
|
|
1635
|
+
const result = await executeWorkflow({
|
|
1636
|
+
workflow: input.workflow,
|
|
1637
|
+
workflowPath: input.workflowPath,
|
|
1638
|
+
params: { ...parameters, action },
|
|
1639
|
+
credentials,
|
|
1640
|
+
initialState: continuation,
|
|
1641
|
+
persistAuthState: false,
|
|
1642
|
+
browser: input.browser,
|
|
1643
|
+
onPreparedRequest: (request) => {
|
|
1644
|
+
prepared = request;
|
|
1645
|
+
},
|
|
1646
|
+
onAuthState: (next) => {
|
|
1647
|
+
credentials = next;
|
|
1648
|
+
},
|
|
1649
|
+
fetchImpl: (async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
|
1650
|
+
if (!prepared)
|
|
1651
|
+
throw new Error('Synthetic response requested without a prepared request.');
|
|
1652
|
+
return await input.respond({ ...prepared, action });
|
|
1653
|
+
}) as typeof fetch,
|
|
1654
|
+
});
|
|
1655
|
+
continuation = result.ok ? undefined : (result.continuation ?? continuation);
|
|
1656
|
+
return result;
|
|
1657
|
+
},
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1298
1661
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
1299
1662
|
if (signal?.aborted) return Promise.resolve();
|
|
1300
1663
|
return new Promise((resolve) => {
|
|
@@ -1603,6 +1966,12 @@ function collectStateCapabilities(workflow: Workflow): Map<string, StateCapabili
|
|
|
1603
1966
|
for (const req of workflow.requests) {
|
|
1604
1967
|
for (const c of req.captures ?? []) out.set(c.name, c.capability);
|
|
1605
1968
|
}
|
|
1969
|
+
for (const action of Object.values(workflow.authConfig?.actions ?? {})) {
|
|
1970
|
+
for (const step of action.steps) {
|
|
1971
|
+
if (step.repeat) out.set(step.repeat.until.name, step.repeat.until.capability);
|
|
1972
|
+
}
|
|
1973
|
+
for (const { when } of action.branches ?? []) out.set(when.name, when.capability);
|
|
1974
|
+
}
|
|
1606
1975
|
return out;
|
|
1607
1976
|
}
|
|
1608
1977
|
|