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.
Files changed (38) hide show
  1. package/README.md +5 -0
  2. package/package.json +1 -1
  3. package/prompts/audit-agent.md +3 -3
  4. package/prompts/auth-compile-agent.md +46 -4
  5. package/prompts/compile-agent.md +36 -12
  6. package/prompts/live-verifier-agent.md +15 -5
  7. package/prompts/request-triage.md +51 -9
  8. package/prompts/tool-candidate-detection.md +7 -1
  9. package/prompts/tool-planning.md +1 -1
  10. package/src/cli.ts +71 -13
  11. package/src/imprint/audit.ts +23 -5
  12. package/src/imprint/auth-compile-agent.ts +11 -7
  13. package/src/imprint/auth-compile-tools.ts +127 -18
  14. package/src/imprint/auth-verifier.ts +2 -1
  15. package/src/imprint/backend-ladder.ts +20 -0
  16. package/src/imprint/build-plan.ts +21 -0
  17. package/src/imprint/claude-cli-compile.ts +7 -1
  18. package/src/imprint/codex-cli-compile.ts +6 -0
  19. package/src/imprint/compile-agent.ts +50 -9
  20. package/src/imprint/compile-tools.ts +308 -25
  21. package/src/imprint/compile-verification.ts +11 -2
  22. package/src/imprint/compile.ts +298 -69
  23. package/src/imprint/effects.ts +9 -0
  24. package/src/imprint/live-verifier.ts +455 -34
  25. package/src/imprint/login.ts +30 -12
  26. package/src/imprint/mcp-compile-server.ts +56 -5
  27. package/src/imprint/mcp-live-verifier-server.ts +43 -10
  28. package/src/imprint/probe-backends.ts +6 -0
  29. package/src/imprint/recording-request.ts +7 -2
  30. package/src/imprint/replay-capture.ts +48 -29
  31. package/src/imprint/request-capture.ts +14 -0
  32. package/src/imprint/runtime.ts +102 -22
  33. package/src/imprint/teach-state.ts +162 -21
  34. package/src/imprint/teach.ts +629 -226
  35. package/src/imprint/tool-candidates.ts +138 -0
  36. package/src/imprint/tool-plan.ts +2 -0
  37. package/src/imprint/triage-selection.ts +44 -0
  38. package/src/imprint/types.ts +39 -3
@@ -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, jsonpath } from './request-capture.ts';
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 = (capture: RequestCapture, request: WorkflowRequest): void => {
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(capture.name);
155
+ const existing = byName.get(credentialName);
149
156
  if (existing && JSON.stringify(existing) !== JSON.stringify(binding)) {
150
157
  throw new Error(
151
- `Persisted capture ${JSON.stringify(capture.name)} has conflicting producing requests. Regenerate the auth workflow with unique capture names.`,
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(capture.name, binding);
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 persisted = new Set(workflow.authConfig?.persist ?? []);
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
- if (!persisted.has(capture.name)) continue;
172
- addCapture(capture, request);
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 && persisted.has(capture.name)) {
180
- addCapture(capture, request);
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 = jsonpath(parsed, capture.path);
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.capture.name] = value;
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 Session, SessionSchema } from './types.ts';
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
- // Widen Fix B's variation pool to dependency requests so a token that
547
- // varies only across them and is frozen as a literal in the tool's
548
- // request is caught (the cross-request session-token leak case).
549
- dependencyRequestSeqs: opts.candidate?.dependencySeqs,
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 credentials = (await loadCredentialStore(workflow.site)) ?? undefined;
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
- await new Promise<void>((resolve) => {
350
- const close = (): void => resolve();
351
- transport.onclose = close;
352
- process.once('SIGINT', close);
353
- process.once('SIGTERM', close);
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
- if (template === recorded) return true;
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(recorded);
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
- const detail = typeof event.detail === 'string' ? event.detail : JSON.stringify(event.detail);
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(`replay threw: ${errMsg(err)}`);
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(page: Page, event: CapturedEvent): Promise<void> {
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(` skip non-http navigation: ${url.slice(0, 80)}`);
260
+ replayLog(' skip non-http navigation');
243
261
  return;
244
262
  }
245
- replayLog(` navigating to ${url.slice(0, 120)}`);
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(` clicking: id=${d.id} name=${d.name} text=${(d.text ?? '').slice(0, 40)}`);
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 (e1) {
276
- replayLog(` click failed (${errMsg(e1).split('\n')[0]}), retrying with force`);
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 (e2) {
281
- replayLog(` force-click also failed: ${errMsg(e2).split('\n')[0]}`);
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(d.value, { timeout: 3_000 });
324
+ await loc.selectOption(resolvedValue, { timeout: 3_000 });
306
325
  } else {
307
- await loc.fill(d.value, { timeout: 3_000 });
326
+ await loc.fill(resolvedValue, { timeout: 3_000 });
308
327
  }
309
328
  replayLog(` ${event.type} succeeded`);
310
- } catch (err) {
311
- replayLog(` ${event.type} failed: ${errMsg(err).split('\n')[0]}`);
329
+ } catch {
330
+ replayLog(` ${event.type} failed`);
312
331
  }
313
332
  return;
314
333
  }
315
334
 
316
335
  if (event.type === 'submit') {
317
- replayLog(` submit: selector=${(d.selector ?? '').slice(0, 60)}`);
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 (err) {
329
- replayLog(` submit failed: ${errMsg(err).split('\n')[0]}`);
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,