imprint-mcp 0.6.4 → 0.6.6
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 +5 -0
- package/package.json +1 -1
- package/prompts/audit-agent.md +3 -3
- package/prompts/auth-compile-agent.md +46 -4
- package/prompts/compile-agent.md +36 -12
- package/prompts/live-verifier-agent.md +15 -5
- package/prompts/request-triage.md +51 -9
- package/prompts/tool-candidate-detection.md +7 -1
- package/prompts/tool-planning.md +1 -1
- package/src/cli.ts +71 -13
- package/src/imprint/audit.ts +23 -5
- package/src/imprint/auth-compile-agent.ts +11 -7
- package/src/imprint/auth-compile-tools.ts +127 -18
- package/src/imprint/auth-verifier.ts +2 -1
- package/src/imprint/backend-ladder.ts +20 -0
- package/src/imprint/build-plan.ts +21 -0
- package/src/imprint/claude-cli-compile.ts +7 -1
- package/src/imprint/codex-cli-compile.ts +6 -0
- package/src/imprint/compile-agent.ts +50 -9
- package/src/imprint/compile-tools.ts +308 -25
- package/src/imprint/compile-verification.ts +11 -2
- package/src/imprint/compile.ts +298 -69
- package/src/imprint/effects.ts +9 -0
- package/src/imprint/live-verifier.ts +455 -34
- package/src/imprint/login.ts +30 -12
- package/src/imprint/mcp-compile-server.ts +56 -5
- package/src/imprint/mcp-live-verifier-server.ts +43 -10
- package/src/imprint/probe-backends.ts +6 -0
- package/src/imprint/recording-request.ts +7 -2
- package/src/imprint/replay-capture.ts +48 -29
- package/src/imprint/request-capture.ts +14 -0
- package/src/imprint/runtime.ts +102 -22
- package/src/imprint/teach-state.ts +162 -21
- package/src/imprint/teach.ts +629 -226
- package/src/imprint/tool-candidates.ts +138 -0
- package/src/imprint/tool-plan.ts +2 -0
- package/src/imprint/triage-selection.ts +44 -0
- package/src/imprint/types.ts +39 -3
package/src/imprint/login.ts
CHANGED
|
@@ -11,13 +11,14 @@ import {
|
|
|
11
11
|
} from './credential-store.ts';
|
|
12
12
|
import { localSiteDir } from './paths.ts';
|
|
13
13
|
import { recordedRequestMatchesWorkflow } from './recording-request.ts';
|
|
14
|
-
import { captureHeader, captureValueMatches,
|
|
14
|
+
import { captureHeader, captureValueMatches, resolveJsonCapture } from './request-capture.ts';
|
|
15
15
|
import {
|
|
16
16
|
type RequestCapture,
|
|
17
17
|
type Session,
|
|
18
18
|
SessionSchema,
|
|
19
19
|
type WorkflowRequest,
|
|
20
20
|
WorkflowSchema,
|
|
21
|
+
persistedCaptureName,
|
|
21
22
|
} from './types.ts';
|
|
22
23
|
|
|
23
24
|
interface LoginOptions {
|
|
@@ -120,6 +121,7 @@ function collectStorage(session: Session): StorageRecord[] {
|
|
|
120
121
|
|
|
121
122
|
/** Gather the request captures named by each auth program's `persist` list. */
|
|
122
123
|
interface SessionCaptureBinding {
|
|
124
|
+
credentialName: string;
|
|
123
125
|
capture: RequestCapture;
|
|
124
126
|
recordingRequestSeq?: number;
|
|
125
127
|
method: string;
|
|
@@ -137,22 +139,27 @@ function collectSessionCaptures(site: string): SessionCaptureBinding[] {
|
|
|
137
139
|
}
|
|
138
140
|
const captures: SessionCaptureBinding[] = [];
|
|
139
141
|
const byName = new Map<string, SessionCaptureBinding>();
|
|
140
|
-
const addCapture = (
|
|
142
|
+
const addCapture = (
|
|
143
|
+
credentialName: string,
|
|
144
|
+
capture: RequestCapture,
|
|
145
|
+
request: WorkflowRequest,
|
|
146
|
+
): void => {
|
|
141
147
|
const binding = {
|
|
148
|
+
credentialName,
|
|
142
149
|
capture,
|
|
143
150
|
recordingRequestSeq: request.recordingRequestSeq,
|
|
144
151
|
method: request.method,
|
|
145
152
|
url: request.url,
|
|
146
153
|
body: request.body,
|
|
147
154
|
};
|
|
148
|
-
const existing = byName.get(
|
|
155
|
+
const existing = byName.get(credentialName);
|
|
149
156
|
if (existing && JSON.stringify(existing) !== JSON.stringify(binding)) {
|
|
150
157
|
throw new Error(
|
|
151
|
-
`Persisted
|
|
158
|
+
`Persisted credential ${JSON.stringify(credentialName)} has conflicting producing requests. Regenerate the auth workflow with unique bindings.`,
|
|
152
159
|
);
|
|
153
160
|
}
|
|
154
161
|
if (!existing) {
|
|
155
|
-
byName.set(
|
|
162
|
+
byName.set(credentialName, binding);
|
|
156
163
|
captures.push(binding);
|
|
157
164
|
}
|
|
158
165
|
};
|
|
@@ -165,19 +172,30 @@ function collectSessionCaptures(site: string): SessionCaptureBinding[] {
|
|
|
165
172
|
} catch {
|
|
166
173
|
continue; // not a tool dir, unreadable, or not a valid workflow
|
|
167
174
|
}
|
|
168
|
-
const
|
|
175
|
+
const config = workflow.authConfig;
|
|
176
|
+
if (!config) continue;
|
|
177
|
+
const persistedByCapture = new Map<string, string[]>();
|
|
178
|
+
for (const credentialName of config.persist) {
|
|
179
|
+
const captureName = persistedCaptureName(config, credentialName);
|
|
180
|
+
const names = persistedByCapture.get(captureName) ?? [];
|
|
181
|
+
names.push(credentialName);
|
|
182
|
+
persistedByCapture.set(captureName, names);
|
|
183
|
+
}
|
|
169
184
|
for (const request of workflow.requests) {
|
|
170
185
|
for (const capture of request.captures ?? []) {
|
|
171
|
-
|
|
172
|
-
|
|
186
|
+
for (const credentialName of persistedByCapture.get(capture.name) ?? []) {
|
|
187
|
+
addCapture(credentialName, capture, request);
|
|
188
|
+
}
|
|
173
189
|
}
|
|
174
190
|
}
|
|
175
191
|
for (const action of Object.values(workflow.authConfig?.actions ?? {})) {
|
|
176
192
|
for (const step of action.steps) {
|
|
177
193
|
const capture = step.repeat?.until;
|
|
178
194
|
const request = workflow.requests[step.request];
|
|
179
|
-
if (capture && request
|
|
180
|
-
|
|
195
|
+
if (capture && request) {
|
|
196
|
+
for (const credentialName of persistedByCapture.get(capture.name) ?? []) {
|
|
197
|
+
addCapture(credentialName, capture, request);
|
|
198
|
+
}
|
|
181
199
|
}
|
|
182
200
|
}
|
|
183
201
|
}
|
|
@@ -220,7 +238,7 @@ function resolveCapture(session: Session, binding: SessionCaptureBinding): strin
|
|
|
220
238
|
} catch {
|
|
221
239
|
continue; // this response isn't JSON; try the next one
|
|
222
240
|
}
|
|
223
|
-
value =
|
|
241
|
+
value = resolveJsonCapture(parsed, capture.path, capture.decodeJsonPath);
|
|
224
242
|
break;
|
|
225
243
|
}
|
|
226
244
|
case 'response_header':
|
|
@@ -250,7 +268,7 @@ export function extractCredentials(site: string, session: Session): Record<strin
|
|
|
250
268
|
const values: Record<string, string> = {};
|
|
251
269
|
for (const binding of collectSessionCaptures(site)) {
|
|
252
270
|
const value = resolveCapture(session, binding);
|
|
253
|
-
if (value !== undefined) values[binding.
|
|
271
|
+
if (value !== undefined) values[binding.credentialName] = value;
|
|
254
272
|
}
|
|
255
273
|
return values;
|
|
256
274
|
}
|
|
@@ -40,11 +40,13 @@ import {
|
|
|
40
40
|
advanceSemanticVerificationCycle,
|
|
41
41
|
} from './compile-agent-types.ts';
|
|
42
42
|
import {
|
|
43
|
+
applyIrreversibleVerificationWaiver,
|
|
43
44
|
applyLiveVerification,
|
|
44
45
|
applyParamVerification,
|
|
45
46
|
buildCompileTools,
|
|
46
47
|
externalVerification,
|
|
47
48
|
} from './compile-tools.ts';
|
|
49
|
+
import { workflowHasIrreversibleEffect } from './effects.ts';
|
|
48
50
|
import {
|
|
49
51
|
isInfrastructureOnlyInconclusiveReport,
|
|
50
52
|
mergeSemanticParamVerification,
|
|
@@ -59,7 +61,8 @@ import { rebindExistingBackendsCacheToWorkflow } from './probe-backends.ts';
|
|
|
59
61
|
import { redactSession } from './redact.ts';
|
|
60
62
|
import { loadCredentialStore } from './runtime.ts';
|
|
61
63
|
import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
|
|
62
|
-
import { type
|
|
64
|
+
import { type SharedTriageSelection, applySharedTriageSelection } from './triage-selection.ts';
|
|
65
|
+
import { type Session, SessionSchema, WorkflowSchema } from './types.ts';
|
|
63
66
|
|
|
64
67
|
const log = createLog('mcp-compile');
|
|
65
68
|
|
|
@@ -88,6 +91,9 @@ interface RunCompileMcpServerOptions {
|
|
|
88
91
|
provider?: ProviderName;
|
|
89
92
|
/** Bounded resume of an already-generated data tool. */
|
|
90
93
|
revisionMode?: boolean;
|
|
94
|
+
/** Shared triage result from teach. Applied before compile tools and done()
|
|
95
|
+
* verification so CLI-backed compilers see irreversible effects. */
|
|
96
|
+
sharedTriageSelection?: SharedTriageSelection;
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
const DONE_SENTINEL = '.compile-done.json';
|
|
@@ -107,6 +113,7 @@ const COMPILE_ARTIFACT_FILES = [
|
|
|
107
113
|
'workflow.json',
|
|
108
114
|
'parser.ts',
|
|
109
115
|
'parser.test.ts',
|
|
116
|
+
'request.test.ts',
|
|
110
117
|
'integration.test.ts',
|
|
111
118
|
'request-transform.ts',
|
|
112
119
|
'playbook.yaml',
|
|
@@ -271,6 +278,12 @@ export async function runCompileMcpServer(opts: RunCompileMcpServerOptions): Pro
|
|
|
271
278
|
if (!looksRedacted) {
|
|
272
279
|
session = redactSession(session).session;
|
|
273
280
|
}
|
|
281
|
+
if (opts.sharedTriageSelection) {
|
|
282
|
+
session = applySharedTriageSelection(session, opts.sharedTriageSelection, {
|
|
283
|
+
candidate: opts.candidate,
|
|
284
|
+
sharedContext: opts.sharedContext,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
274
287
|
|
|
275
288
|
// Build the toolset. Auth mode swaps the data tools (parser/test-oriented)
|
|
276
289
|
// for the auth toolset, which lives in auth-compile-tools.ts so the in-process
|
|
@@ -543,10 +556,11 @@ Fix the issues in workflow.json, re-test with run_verification, and call done ag
|
|
|
543
556
|
expectedToolName: opts.candidate?.toolName,
|
|
544
557
|
likelyParams: opts.candidate?.likelyParams,
|
|
545
558
|
candidateRequestSeqs: opts.candidate?.requestSeqs,
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
559
|
+
// Include bootstrap and shared login dependencies in provenance checks.
|
|
560
|
+
dependencyRequestSeqs: [
|
|
561
|
+
...(opts.candidate?.dependencySeqs ?? []),
|
|
562
|
+
...(opts.sharedContext?.loginRequestSeqs ?? []),
|
|
563
|
+
],
|
|
550
564
|
assignedSharedModules,
|
|
551
565
|
tokenParams,
|
|
552
566
|
tokenParamShapes,
|
|
@@ -559,6 +573,43 @@ Fix the issues in workflow.json, re-test with run_verification, and call done ag
|
|
|
559
573
|
if (warnings.length > 0) {
|
|
560
574
|
log(`verification warnings (non-blocking):\n${warnings.join('\n')}`);
|
|
561
575
|
}
|
|
576
|
+
let workflow: ReturnType<typeof WorkflowSchema.parse> | undefined;
|
|
577
|
+
if (failures.length === 0) {
|
|
578
|
+
workflow = WorkflowSchema.parse(
|
|
579
|
+
JSON.parse(readFileSync(pathJoin(opts.toolDir, 'workflow.json'), 'utf8')),
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
if (workflow && workflowHasIrreversibleEffect(workflow)) {
|
|
583
|
+
const allWarnings = [
|
|
584
|
+
...warnings,
|
|
585
|
+
...applyIrreversibleVerificationWaiver(opts.toolDir, workflow),
|
|
586
|
+
];
|
|
587
|
+
const sentinel = pathJoin(opts.toolDir, DONE_SENTINEL);
|
|
588
|
+
writeFileSync(
|
|
589
|
+
sentinel,
|
|
590
|
+
JSON.stringify(
|
|
591
|
+
{
|
|
592
|
+
summary,
|
|
593
|
+
verification: 'passed',
|
|
594
|
+
liveVerified: false,
|
|
595
|
+
safetyWaiver: 'irreversible',
|
|
596
|
+
warnings: allWarnings,
|
|
597
|
+
timestamp: Date.now(),
|
|
598
|
+
},
|
|
599
|
+
null,
|
|
600
|
+
2,
|
|
601
|
+
),
|
|
602
|
+
'utf8',
|
|
603
|
+
);
|
|
604
|
+
return {
|
|
605
|
+
content: [
|
|
606
|
+
{
|
|
607
|
+
type: 'text',
|
|
608
|
+
text: 'DONE_UNVERIFIED — deterministic verification passed; live verification was skipped because the workflow is irreversible.',
|
|
609
|
+
},
|
|
610
|
+
],
|
|
611
|
+
};
|
|
612
|
+
}
|
|
562
613
|
if (failures.length === 0) {
|
|
563
614
|
if (!opts.provider) {
|
|
564
615
|
failures.push('compile provider was not supplied to the independent semantic verifier');
|
|
@@ -12,8 +12,12 @@ import {
|
|
|
12
12
|
type LiveIntegrationEvidence,
|
|
13
13
|
runCapturedIntegrationCase,
|
|
14
14
|
} from './compile-verification.ts';
|
|
15
|
+
import { workflowHasIrreversibleEffect } from './effects.ts';
|
|
15
16
|
import {
|
|
17
|
+
LIVE_AUTH_REFRESH_INPUT_SCHEMA,
|
|
18
|
+
LIVE_AUTH_REFRESH_TOOL_DESCRIPTION,
|
|
16
19
|
LIVE_VERIFICATION_EVIDENCE_FILE,
|
|
20
|
+
type LiveVerificationAuthSession,
|
|
17
21
|
LiveVerificationReportSchema,
|
|
18
22
|
appendLiveVerifierLog,
|
|
19
23
|
assertReportCoversWorkflowParameters,
|
|
@@ -22,9 +26,9 @@ import {
|
|
|
22
26
|
persistLiveVerificationEvidence,
|
|
23
27
|
prepareLiveVerificationBackend,
|
|
24
28
|
readPersistedLiveVerificationEvidence,
|
|
29
|
+
runLiveAuthRefreshToolCall,
|
|
25
30
|
runLiveIntegrationSuite,
|
|
26
31
|
} from './live-verifier.ts';
|
|
27
|
-
import { loadCredentialStore } from './runtime.ts';
|
|
28
32
|
import { buildZodValidator } from './tool-loader.ts';
|
|
29
33
|
import { WorkflowSchema } from './types.ts';
|
|
30
34
|
|
|
@@ -79,6 +83,12 @@ const PREPARE_BACKEND_TOOL: Tool = {
|
|
|
79
83
|
},
|
|
80
84
|
};
|
|
81
85
|
|
|
86
|
+
const REFRESH_AUTH_TOOL: Tool = {
|
|
87
|
+
name: 'refresh_auth_session',
|
|
88
|
+
description: LIVE_AUTH_REFRESH_TOOL_DESCRIPTION,
|
|
89
|
+
inputSchema: LIVE_AUTH_REFRESH_INPUT_SCHEMA,
|
|
90
|
+
};
|
|
91
|
+
|
|
82
92
|
const RUN_SUITE_TOOL: Tool = {
|
|
83
93
|
name: 'run_live_integration_suite',
|
|
84
94
|
description:
|
|
@@ -156,8 +166,15 @@ export async function runLiveVerifierMcpServer(opts: {
|
|
|
156
166
|
attempt?: number;
|
|
157
167
|
}): Promise<void> {
|
|
158
168
|
const workflow = WorkflowSchema.parse(JSON.parse(readFileSync(opts.workflowPath, 'utf8')));
|
|
169
|
+
if (workflowHasIrreversibleEffect(workflow)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Live verification is disabled for irreversible workflow ${JSON.stringify(workflow.toolName)}.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
159
174
|
const validator = buildZodValidator(workflow.parameters);
|
|
160
|
-
const
|
|
175
|
+
const configuredDeadline = Number(process.env.IMPRINT_LIVE_VERIFIER_DEADLINE_MS);
|
|
176
|
+
const deadlineMs = Number.isFinite(configuredDeadline) ? configuredDeadline : undefined;
|
|
177
|
+
let authSession: LiveVerificationAuthSession | undefined;
|
|
161
178
|
let submitted = false;
|
|
162
179
|
const backendPreparation: {
|
|
163
180
|
current?: {
|
|
@@ -180,9 +197,22 @@ export async function runLiveVerifierMcpServer(opts: {
|
|
|
180
197
|
{ capabilities: { tools: {} } },
|
|
181
198
|
);
|
|
182
199
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
183
|
-
tools: [PREPARE_BACKEND_TOOL, RUN_SUITE_TOOL, RUN_TOOL, SUBMIT_TOOL],
|
|
200
|
+
tools: [REFRESH_AUTH_TOOL, PREPARE_BACKEND_TOOL, RUN_SUITE_TOOL, RUN_TOOL, SUBMIT_TOOL],
|
|
184
201
|
}));
|
|
185
202
|
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
|
203
|
+
if (request.params.name === 'refresh_auth_session') {
|
|
204
|
+
const result = await runLiveAuthRefreshToolCall(authSession, request.params.arguments, {
|
|
205
|
+
workflowPath: opts.workflowPath,
|
|
206
|
+
logPath: opts.logPath,
|
|
207
|
+
attempt: opts.attempt,
|
|
208
|
+
sessionLabel: opts.sessionLabel,
|
|
209
|
+
deadlineMs,
|
|
210
|
+
});
|
|
211
|
+
authSession = result.session;
|
|
212
|
+
return result.error
|
|
213
|
+
? errorResult(result.error)
|
|
214
|
+
: { content: [{ type: 'text', text: JSON.stringify(result.observation) }] };
|
|
215
|
+
}
|
|
186
216
|
if (request.params.name === 'prepare_live_backend') {
|
|
187
217
|
const input = request.params.arguments as {
|
|
188
218
|
params?: unknown;
|
|
@@ -259,7 +289,6 @@ export async function runLiveVerifierMcpServer(opts: {
|
|
|
259
289
|
caseName: label,
|
|
260
290
|
workflowPath: opts.workflowPath,
|
|
261
291
|
params: parsed.data as Record<string, string | number | boolean>,
|
|
262
|
-
credentials,
|
|
263
292
|
preferredOnlyBackend: true,
|
|
264
293
|
});
|
|
265
294
|
} catch (error) {
|
|
@@ -346,12 +375,16 @@ export async function runLiveVerifierMcpServer(opts: {
|
|
|
346
375
|
// process lifetime. Keep stdio alive until the verifier disconnects, just
|
|
347
376
|
// like the compile MCP server does, or Codex can lose the server during its
|
|
348
377
|
// initialize handshake.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
378
|
+
try {
|
|
379
|
+
await new Promise<void>((resolve) => {
|
|
380
|
+
const close = (): void => resolve();
|
|
381
|
+
transport.onclose = close;
|
|
382
|
+
process.once('SIGINT', close);
|
|
383
|
+
process.once('SIGTERM', close);
|
|
384
|
+
});
|
|
385
|
+
} finally {
|
|
386
|
+
await authSession?.close();
|
|
387
|
+
}
|
|
355
388
|
}
|
|
356
389
|
|
|
357
390
|
function errorResult(message: string): CallToolResult {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from './backend-cache.ts';
|
|
16
16
|
import { prefersCdpReplayFirst, runWithLadder } from './backend-ladder.ts';
|
|
17
17
|
import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
|
|
18
|
+
import { workflowHasIrreversibleEffect } from './effects.ts';
|
|
18
19
|
import { createLog } from './log.ts';
|
|
19
20
|
import { imprintHomeDir } from './paths.ts';
|
|
20
21
|
import { availableSitesHint } from './sites.ts';
|
|
@@ -114,6 +115,11 @@ export async function probeResolvedTool(
|
|
|
114
115
|
tool: ResolvedTool,
|
|
115
116
|
explicitOutPath?: string,
|
|
116
117
|
): Promise<ProbeBackendsResult> {
|
|
118
|
+
if (workflowHasIrreversibleEffect(tool.workflow)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Backend probing is disabled for irreversible workflow "${tool.workflow.toolName}". Invoke it only through an intentional production tool call.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
117
123
|
const outPath = explicitOutPath ?? pathResolve(tool.dir, 'backends.json');
|
|
118
124
|
|
|
119
125
|
const params = resolveParams(tool, opts.paramOverrides);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CapturedRequest, WorkflowRequest } from './types.ts';
|
|
2
2
|
|
|
3
3
|
const TEMPLATE_PLACEHOLDER = /\$\{[^}]+\}/g;
|
|
4
|
+
const EMPTY_REDACTION = /\[REDACTED:v3:id=\d+:len=0\]/g;
|
|
4
5
|
|
|
5
6
|
function escapeRegExp(value: string): string {
|
|
6
7
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -9,7 +10,11 @@ function escapeRegExp(value: string): string {
|
|
|
9
10
|
/** Match a concrete recorded value against a workflow template without
|
|
10
11
|
* interpreting or substituting the dynamic values themselves. */
|
|
11
12
|
function templateMatchesRecorded(template: string, recorded: string): boolean {
|
|
12
|
-
|
|
13
|
+
// Structured redaction preserves the original length. A sensitive field
|
|
14
|
+
// whose recorded value was empty is therefore still represented by a
|
|
15
|
+
// marker, even though the executable workflow must send the empty string.
|
|
16
|
+
const groundedRecorded = recorded.replace(EMPTY_REDACTION, '');
|
|
17
|
+
if (template === groundedRecorded) return true;
|
|
13
18
|
let cursor = 0;
|
|
14
19
|
let pattern = '^';
|
|
15
20
|
for (const match of template.matchAll(TEMPLATE_PLACEHOLDER)) {
|
|
@@ -19,7 +24,7 @@ function templateMatchesRecorded(template: string, recorded: string): boolean {
|
|
|
19
24
|
}
|
|
20
25
|
if (cursor === 0) return false;
|
|
21
26
|
pattern += `${escapeRegExp(template.slice(cursor))}$`;
|
|
22
|
-
return new RegExp(pattern).test(
|
|
27
|
+
return new RegExp(pattern).test(groundedRecorded);
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
export function recordedRequestMatchesWorkflow(
|
|
@@ -52,6 +52,23 @@ interface RawReplayOptions {
|
|
|
52
52
|
onProgress?: (current: number, total: number, captured: number) => void;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const CREDENTIAL_PLACEHOLDER = /^\$\{credential\.([A-Za-z0-9_.-]+)\}$/;
|
|
56
|
+
|
|
57
|
+
/** Resolve only a complete credential placeholder. Embedded or missing
|
|
58
|
+
* placeholders are rejected so replay never types placeholder text or a partial
|
|
59
|
+
* secret interpolation into the page. */
|
|
60
|
+
export function resolveReplayEventValue(
|
|
61
|
+
value: string,
|
|
62
|
+
credentials: Record<string, string>,
|
|
63
|
+
): string | null {
|
|
64
|
+
const match = CREDENTIAL_PLACEHOLDER.exec(value);
|
|
65
|
+
if (match) {
|
|
66
|
+
const name = match[1];
|
|
67
|
+
return name && Object.hasOwn(credentials, name) ? (credentials[name] ?? null) : null;
|
|
68
|
+
}
|
|
69
|
+
return value.includes('${credential.') ? null : value;
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
/**
|
|
56
73
|
* Replay the raw DOM events from an original recording in a fresh browser,
|
|
57
74
|
* capturing all network requests. This is the site-level dual-pass strategy:
|
|
@@ -82,16 +99,17 @@ export async function replayRawSession(opts: RawReplayOptions): Promise<ReplayCa
|
|
|
82
99
|
const captured: CapturedReplayRequest[] = [];
|
|
83
100
|
let seq = 0;
|
|
84
101
|
const startTime = Date.now();
|
|
85
|
-
|
|
86
102
|
try {
|
|
87
103
|
context = await browser.newContext();
|
|
88
104
|
const page = await context.newPage();
|
|
89
105
|
replayLog('browser context + page created');
|
|
90
106
|
|
|
91
107
|
// Inject credentials if available
|
|
108
|
+
let credentialValues: Record<string, string> = {};
|
|
92
109
|
try {
|
|
93
110
|
const { loadSiteCredentials } = await import('./credential-store.ts');
|
|
94
111
|
const view = await loadSiteCredentials(opts.site);
|
|
112
|
+
credentialValues = view.values;
|
|
95
113
|
const playwrightCookies = view.cookies
|
|
96
114
|
.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path }))
|
|
97
115
|
.filter((c) => c.name && c.value);
|
|
@@ -197,13 +215,9 @@ export async function replayRawSession(opts: RawReplayOptions): Promise<ReplayCa
|
|
|
197
215
|
}
|
|
198
216
|
prevTimestamp = event.timestamp;
|
|
199
217
|
|
|
200
|
-
|
|
201
|
-
const detailPreview = detail.length > 200 ? `${detail.slice(0, 200)}...` : detail;
|
|
202
|
-
replayLog(
|
|
203
|
-
`event ${i + 1}/${replayableEvents.length}: type=${event.type} seq=${event.seq} detail=${detailPreview}`,
|
|
204
|
-
);
|
|
218
|
+
replayLog(`event ${i + 1}/${replayableEvents.length}: type=${event.type} seq=${event.seq}`);
|
|
205
219
|
|
|
206
|
-
await replayEvent(page, event);
|
|
220
|
+
await replayEvent(page, event, credentialValues);
|
|
207
221
|
replayLog(` event ${i + 1} done (captured ${captured.length} requests so far)`);
|
|
208
222
|
opts.onProgress?.(i + 1, replayableEvents.length, captured.length);
|
|
209
223
|
}
|
|
@@ -227,7 +241,7 @@ export async function replayRawSession(opts: RawReplayOptions): Promise<ReplayCa
|
|
|
227
241
|
log(`captured ${captured.length} requests during raw session replay`);
|
|
228
242
|
return { ok: true, requests: captured };
|
|
229
243
|
} catch (err) {
|
|
230
|
-
replayLog(
|
|
244
|
+
replayLog('replay stopped after a browser operation failed');
|
|
231
245
|
return { ok: false, requests: captured, error: errMsg(err) };
|
|
232
246
|
} finally {
|
|
233
247
|
await context?.close().catch(() => {});
|
|
@@ -235,14 +249,18 @@ export async function replayRawSession(opts: RawReplayOptions): Promise<ReplayCa
|
|
|
235
249
|
}
|
|
236
250
|
}
|
|
237
251
|
|
|
238
|
-
async function replayEvent(
|
|
252
|
+
async function replayEvent(
|
|
253
|
+
page: Page,
|
|
254
|
+
event: CapturedEvent,
|
|
255
|
+
credentials: Record<string, string>,
|
|
256
|
+
): Promise<void> {
|
|
239
257
|
if (event.type === 'navigation') {
|
|
240
258
|
const url = typeof event.detail === 'string' ? event.detail : String(event.detail);
|
|
241
259
|
if (!url.startsWith('http')) {
|
|
242
|
-
replayLog(
|
|
260
|
+
replayLog(' skip non-http navigation');
|
|
243
261
|
return;
|
|
244
262
|
}
|
|
245
|
-
replayLog(
|
|
263
|
+
replayLog(' navigating');
|
|
246
264
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
247
265
|
replayLog(' navigation complete');
|
|
248
266
|
return;
|
|
@@ -257,12 +275,10 @@ async function replayEvent(page: Page, event: CapturedEvent): Promise<void> {
|
|
|
257
275
|
if (event.type === 'click') {
|
|
258
276
|
const loc = buildLocatorFromEvent(page, d);
|
|
259
277
|
if (!loc) {
|
|
260
|
-
replayLog(
|
|
261
|
-
` skip click: no locator for id=${d.id} name=${d.name} text=${(d.text ?? '').slice(0, 40)} selector=${(d.selector ?? '').slice(0, 60)}`,
|
|
262
|
-
);
|
|
278
|
+
replayLog(' skip click: no locator');
|
|
263
279
|
return;
|
|
264
280
|
}
|
|
265
|
-
replayLog(
|
|
281
|
+
replayLog(' clicking');
|
|
266
282
|
try {
|
|
267
283
|
// Fast visibility check — don't wait 10s for elements that aren't there
|
|
268
284
|
const visible = await loc.isVisible().catch(() => false);
|
|
@@ -272,13 +288,13 @@ async function replayEvent(page: Page, event: CapturedEvent): Promise<void> {
|
|
|
272
288
|
}
|
|
273
289
|
await loc.click({ timeout: 3_000, force: false });
|
|
274
290
|
replayLog(' click succeeded');
|
|
275
|
-
} catch
|
|
276
|
-
replayLog(
|
|
291
|
+
} catch {
|
|
292
|
+
replayLog(' click failed, retrying with force');
|
|
277
293
|
try {
|
|
278
294
|
await loc.click({ timeout: 2_000, force: true });
|
|
279
295
|
replayLog(' force-click succeeded');
|
|
280
|
-
} catch
|
|
281
|
-
replayLog(
|
|
296
|
+
} catch {
|
|
297
|
+
replayLog(' force-click also failed');
|
|
282
298
|
}
|
|
283
299
|
}
|
|
284
300
|
return;
|
|
@@ -290,11 +306,14 @@ async function replayEvent(page: Page, event: CapturedEvent): Promise<void> {
|
|
|
290
306
|
replayLog(` skip ${event.type}: no locator or no value`);
|
|
291
307
|
return;
|
|
292
308
|
}
|
|
309
|
+
const resolvedValue = resolveReplayEventValue(d.value, credentials);
|
|
310
|
+
if (resolvedValue === null) {
|
|
311
|
+
replayLog(` skip ${event.type}: credential placeholder could not be resolved safely`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
293
314
|
const tag = (d.tag ?? '').toLowerCase();
|
|
294
315
|
const type = (d.type ?? '').toLowerCase();
|
|
295
|
-
replayLog(
|
|
296
|
-
` ${event.type}: tag=${tag} type=${type} name=${d.name} value=${(d.value ?? '').slice(0, 40)}`,
|
|
297
|
-
);
|
|
316
|
+
replayLog(` ${event.type}: tag=${tag} type=${type}`);
|
|
298
317
|
try {
|
|
299
318
|
const visible = await loc.isVisible().catch(() => false);
|
|
300
319
|
if (!visible) {
|
|
@@ -302,19 +321,19 @@ async function replayEvent(page: Page, event: CapturedEvent): Promise<void> {
|
|
|
302
321
|
return;
|
|
303
322
|
}
|
|
304
323
|
if (tag === 'select' || type === 'select-one') {
|
|
305
|
-
await loc.selectOption(
|
|
324
|
+
await loc.selectOption(resolvedValue, { timeout: 3_000 });
|
|
306
325
|
} else {
|
|
307
|
-
await loc.fill(
|
|
326
|
+
await loc.fill(resolvedValue, { timeout: 3_000 });
|
|
308
327
|
}
|
|
309
328
|
replayLog(` ${event.type} succeeded`);
|
|
310
|
-
} catch
|
|
311
|
-
replayLog(` ${event.type} failed
|
|
329
|
+
} catch {
|
|
330
|
+
replayLog(` ${event.type} failed`);
|
|
312
331
|
}
|
|
313
332
|
return;
|
|
314
333
|
}
|
|
315
334
|
|
|
316
335
|
if (event.type === 'submit') {
|
|
317
|
-
replayLog(
|
|
336
|
+
replayLog(' submit');
|
|
318
337
|
const loc = d.selector ? page.locator(d.selector) : null;
|
|
319
338
|
if (loc) {
|
|
320
339
|
try {
|
|
@@ -325,8 +344,8 @@ async function replayEvent(page: Page, event: CapturedEvent): Promise<void> {
|
|
|
325
344
|
}
|
|
326
345
|
await loc.press('Enter', { timeout: 3_000 });
|
|
327
346
|
replayLog(' submit succeeded');
|
|
328
|
-
} catch
|
|
329
|
-
replayLog(
|
|
347
|
+
} catch {
|
|
348
|
+
replayLog(' submit failed');
|
|
330
349
|
}
|
|
331
350
|
}
|
|
332
351
|
return;
|
|
@@ -66,6 +66,20 @@ export function jsonpath(root: unknown, path: string): unknown {
|
|
|
66
66
|
return cur;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** Resolve a JSON response capture, optionally decoding one JSON-string
|
|
70
|
+
* envelope before applying a second path. Invalid or non-string envelopes are
|
|
71
|
+
* treated like any other missing capture. */
|
|
72
|
+
export function resolveJsonCapture(root: unknown, path: string, decodeJsonPath?: string): unknown {
|
|
73
|
+
const value = jsonpath(root, path);
|
|
74
|
+
if (decodeJsonPath === undefined) return value;
|
|
75
|
+
if (typeof value !== 'string') return undefined;
|
|
76
|
+
try {
|
|
77
|
+
return jsonpath(JSON.parse(value), decodeJsonPath);
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
export function captureValueMatches(
|
|
70
84
|
value: unknown,
|
|
71
85
|
equals?: string | number | boolean | null,
|