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
package/src/cli.ts CHANGED
@@ -40,6 +40,45 @@ export function parseDuration(dur: string): number | null {
40
40
  return num;
41
41
  }
42
42
 
43
+ export type TeachSelectionMode =
44
+ | 'tool'
45
+ | 'primary'
46
+ | 'all-explicit'
47
+ | 'all-default'
48
+ | 'interactive-default-all';
49
+
50
+ /** Pure CLI selection-mode resolver shared by validation and telemetry. */
51
+ export function resolveTeachSelectionMode(opts: {
52
+ tool?: string;
53
+ primaryTool?: boolean;
54
+ allTools?: boolean;
55
+ noInteractive?: boolean;
56
+ }): { mode: TeachSelectionMode; error?: string } {
57
+ if (opts.tool && opts.allTools) {
58
+ return { mode: 'tool', error: 'error: --tool cannot be combined with --all-tools' };
59
+ }
60
+ if (opts.tool && opts.primaryTool) {
61
+ return { mode: 'tool', error: 'error: --tool cannot be combined with --primary-tool' };
62
+ }
63
+ if (opts.allTools && opts.primaryTool) {
64
+ return {
65
+ mode: 'primary',
66
+ error: 'error: --all-tools cannot be combined with --primary-tool',
67
+ };
68
+ }
69
+ return {
70
+ mode: opts.tool
71
+ ? 'tool'
72
+ : opts.primaryTool
73
+ ? 'primary'
74
+ : opts.allTools
75
+ ? 'all-explicit'
76
+ : opts.noInteractive
77
+ ? 'all-default'
78
+ : 'interactive-default-all',
79
+ };
80
+ }
81
+
43
82
  const HELP = `imprint v${VERSION} — teach an AI agent to use any website. Once.
44
83
 
45
84
  USAGE
@@ -112,7 +151,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
112
151
  summary:
113
152
  'Record a workflow, compile both artifacts, emit the tool, and connect to your AI platform — all in one interactive flow. Supports resuming incomplete runs and multiple workflows per site.',
114
153
  usage: [
115
- 'imprint teach <site> [--url <url>] [--from-session <path>] [--persist-profile] [--no-interactive] [--all-tools] [--tool <toolName>] [--provider <name>] [--model <name>] [--timeout <duration>] [--keep-test] [--skip-replay] [--from-step <step>] [--to-step <step>] [--only <step>]',
154
+ 'imprint teach <site> [--url <url>] [--from-session <path>] [--persist-profile] [--no-interactive] [--primary-tool | --all-tools] [--tool <toolName>] [--provider <name>] [--model <name>] [--timeout <duration>] [--keep-test] [--skip-replay] [--from-step <step>] [--to-step <step>] [--only <step>]',
116
155
  ],
117
156
  flags: [
118
157
  { name: '--url <url>', description: 'Starting URL (else about:blank).' },
@@ -123,13 +162,15 @@ export const VERB_HELP: Record<string, VerbHelp> = {
123
162
  { name: '--persist-profile', description: 'Reuse a stable Chrome profile for this site.' },
124
163
  {
125
164
  name: '--no-interactive',
126
- description:
127
- 'Run without prompts; compile the primary detected tool and print integration snippets.',
165
+ description: 'Run without prompts; compile all detected tools by default.',
128
166
  },
129
167
  {
130
168
  name: '--all-tools',
131
- description:
132
- 'With --no-interactive, compile every detected candidate tool instead of only the primary.',
169
+ description: 'Explicitly compile all detected tools (the default).',
170
+ },
171
+ {
172
+ name: '--primary-tool',
173
+ description: 'Compile only the primary detected tool and its upstream dependencies.',
133
174
  },
134
175
  {
135
176
  name: '--tool <toolName>',
@@ -153,17 +194,17 @@ export const VERB_HELP: Record<string, VerbHelp> = {
153
194
  {
154
195
  name: '--keep-test',
155
196
  description:
156
- 'Retain the agent-generated parser.test.ts after compile (debug). Default deletes it; the test reads the gitignored redacted session via $IMPRINT_SESSION_PATH and is not portable. Also settable via IMPRINT_KEEP_TEST=1.',
197
+ 'Retain the agent-generated parser.test.ts and request.test.ts after compile (debug). Default deletes them; parser tests read the gitignored redacted session via $IMPRINT_SESSION_PATH and are not portable. Also settable via IMPRINT_KEEP_TEST=1.',
157
198
  },
158
199
  {
159
200
  name: '--skip-replay',
160
201
  description:
161
- "Skip the replay-and-diff stage. Faster, but the compile agent won't be able to distinguish browser-minted values from constants, which may reduce workflow accuracy.",
202
+ "Skip the replay-and-diff stage. Faster, but the compile agent won't be able to distinguish browser-minted values from constants, which may reduce workflow accuracy. By default, the preceding effect-aware triage suppresses replay entirely when the recording contains an irreversible request.",
162
203
  },
163
204
  {
164
205
  name: '--from-step <step>',
165
206
  description:
166
- 'Resume a prior run starting at <step> (record, redact, replay-and-diff, triage, detect-candidates, plan-prereqs, generate, compile-playbook, emit, register). Only allowed if a prior run reached/crossed that point — earlier phase outputs are reused. Not combinable with --from-session.',
207
+ 'Resume a prior run starting at <step> (record, redact, triage, replay-and-diff, detect-candidates, plan-prereqs, generate, compile-playbook, emit, register). Only allowed if a prior run reached/crossed that point — earlier phase outputs are reused. Not combinable with --from-session.',
167
208
  },
168
209
  {
169
210
  name: '--to-step <step>',
@@ -224,7 +265,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
224
265
  {
225
266
  name: '--keep-test',
226
267
  description:
227
- 'Retain the agent-generated parser.test.ts after compile (debug). Default deletes it; the test reads the gitignored redacted session via $IMPRINT_SESSION_PATH and is not portable. Also settable via IMPRINT_KEEP_TEST=1.',
268
+ 'Retain the agent-generated parser.test.ts and request.test.ts after compile (debug). Default deletes them; parser tests read the gitignored redacted session via $IMPRINT_SESSION_PATH and are not portable. Also settable via IMPRINT_KEEP_TEST=1.',
228
269
  },
229
270
  ],
230
271
  example: 'imprint generate ~/.imprint/acmecorp/sessions/<ts>.redacted.json',
@@ -1423,6 +1464,7 @@ async function main(argv: string[]): Promise<number> {
1423
1464
  'persist-profile': { type: 'boolean' },
1424
1465
  'no-interactive': { type: 'boolean' },
1425
1466
  'all-tools': { type: 'boolean' },
1467
+ 'primary-tool': { type: 'boolean' },
1426
1468
  tool: { type: 'string' },
1427
1469
  provider: { type: 'string' },
1428
1470
  model: { type: 'string' },
@@ -1464,8 +1506,14 @@ async function main(argv: string[]): Promise<number> {
1464
1506
  return 2;
1465
1507
  }
1466
1508
  }
1467
- if (values.tool && values['all-tools']) {
1468
- console.error('error: --tool cannot be combined with --all-tools');
1509
+ const selection = resolveTeachSelectionMode({
1510
+ tool: values.tool,
1511
+ primaryTool: values['primary-tool'],
1512
+ allTools: values['all-tools'],
1513
+ noInteractive: values['no-interactive'],
1514
+ });
1515
+ if (selection.error) {
1516
+ console.error(selection.error);
1469
1517
  return 2;
1470
1518
  }
1471
1519
 
@@ -1500,6 +1548,7 @@ async function main(argv: string[]): Promise<number> {
1500
1548
  const ctrl = new AbortController();
1501
1549
  const onSigint = (): void => ctrl.abort();
1502
1550
  process.once('SIGINT', onSigint);
1551
+ const teachSelectionMode = selection.mode;
1503
1552
 
1504
1553
  try {
1505
1554
  const { teach } = await import('./imprint/teach.ts');
@@ -1513,7 +1562,10 @@ async function main(argv: string[]): Promise<number> {
1513
1562
  'imprint.provider': values.provider ?? 'auto',
1514
1563
  'imprint.model': values.model ?? 'auto',
1515
1564
  'imprint.timeout_ms': teachTimeoutMs ?? 'default',
1516
- 'imprint.all_tools': values['all-tools'] ?? false,
1565
+ 'imprint.all_tools':
1566
+ teachSelectionMode === 'all-explicit' || teachSelectionMode === 'all-default',
1567
+ 'imprint.selection_mode': teachSelectionMode,
1568
+ 'imprint.primary_tool': values['primary-tool'] ?? false,
1517
1569
  'imprint.tool': values.tool,
1518
1570
  'imprint.no_interactive': values['no-interactive'] ?? false,
1519
1571
  'imprint.skip_replay': values['skip-replay'] ?? false,
@@ -1530,7 +1582,7 @@ async function main(argv: string[]): Promise<number> {
1530
1582
  model: values.model,
1531
1583
  maxDurationMs: teachTimeoutMs,
1532
1584
  keepTest: values['keep-test'] || process.env.IMPRINT_KEEP_TEST === '1',
1533
- allTools: values['all-tools'],
1585
+ primaryTool: values['primary-tool'],
1534
1586
  tool: values.tool,
1535
1587
  skipReplay: values['skip-replay'],
1536
1588
  fromStep: fromStepArg,
@@ -1570,6 +1622,7 @@ async function main(argv: string[]): Promise<number> {
1570
1622
  'revision-mode': { type: 'boolean' },
1571
1623
  provider: { type: 'string' },
1572
1624
  site: { type: 'string' },
1625
+ 'shared-triage-json': { type: 'string' },
1573
1626
  },
1574
1627
  allowPositionals: false,
1575
1628
  });
@@ -1587,6 +1640,7 @@ async function main(argv: string[]): Promise<number> {
1587
1640
  const { SharedModuleManifestSchema, AuthToolPlanSchema } = await import(
1588
1641
  './imprint/build-plan.ts'
1589
1642
  );
1643
+ const { SharedTriageSelectionSchema } = await import('./imprint/triage-selection.ts');
1590
1644
  const candidate = values['candidate-json']
1591
1645
  ? ToolCandidateSchema.parse(JSON.parse(values['candidate-json']))
1592
1646
  : undefined;
@@ -1599,6 +1653,9 @@ async function main(argv: string[]): Promise<number> {
1599
1653
  const authToolPlan = values['auth-plan-json']
1600
1654
  ? AuthToolPlanSchema.parse(JSON.parse(values['auth-plan-json']))
1601
1655
  : undefined;
1656
+ const sharedTriageSelection = values['shared-triage-json']
1657
+ ? SharedTriageSelectionSchema.parse(JSON.parse(values['shared-triage-json']))
1658
+ : undefined;
1602
1659
  await runCompileMcpServer({
1603
1660
  sessionPath: values['session-path'],
1604
1661
  toolDir,
@@ -1615,6 +1672,7 @@ async function main(argv: string[]): Promise<number> {
1615
1672
  | 'cursor-cli'
1616
1673
  | undefined,
1617
1674
  site: values.site,
1675
+ sharedTriageSelection,
1618
1676
  });
1619
1677
  return 0;
1620
1678
  }
@@ -21,6 +21,7 @@ import {
21
21
  type AuthVerificationReceiptStatus,
22
22
  readAuthVerificationReceiptStatus,
23
23
  } from './auth-compile-tools.ts';
24
+ import { workflowHasIrreversibleEffect } from './effects.ts';
24
25
  import { type ProviderName, preferredAgentModel } from './llm.ts';
25
26
  import { createLog } from './log.ts';
26
27
  import { imprintHomeDir } from './paths.ts';
@@ -129,6 +130,7 @@ interface AuditToolPartition {
129
130
  eligible: ResolvedTool[];
130
131
  nonInteractiveAuthNames: string[];
131
132
  skippedInteractiveAuth: SkippedInteractiveAuth[];
133
+ skippedIrreversible: string[];
132
134
  }
133
135
 
134
136
  interface DetectedButUnemittedTool {
@@ -185,8 +187,13 @@ export function partitionAuditTools(tools: readonly ResolvedTool[]): AuditToolPa
185
187
  const eligible: ResolvedTool[] = [];
186
188
  const nonInteractiveAuthNames: string[] = [];
187
189
  const skippedInteractiveAuth: SkippedInteractiveAuth[] = [];
190
+ const skippedIrreversible: string[] = [];
188
191
 
189
192
  for (const tool of tools) {
193
+ if (workflowHasIrreversibleEffect(tool.workflow)) {
194
+ skippedIrreversible.push(tool.workflow.toolName);
195
+ continue;
196
+ }
190
197
  // authConfig is the authoritative capability marker for legacy workflows
191
198
  // where the newer optional toolKind field may be absent.
192
199
  const auth = tool.workflow.authConfig;
@@ -229,7 +236,7 @@ export function partitionAuditTools(tools: readonly ResolvedTool[]): AuditToolPa
229
236
  });
230
237
  }
231
238
 
232
- return { eligible, nonInteractiveAuthNames, skippedInteractiveAuth };
239
+ return { eligible, nonInteractiveAuthNames, skippedInteractiveAuth, skippedIrreversible };
233
240
  }
234
241
 
235
242
  /**
@@ -566,6 +573,7 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
566
573
  eligible: tools,
567
574
  nonInteractiveAuthNames,
568
575
  skippedInteractiveAuth,
576
+ skippedIrreversible,
569
577
  } = partitionAuditTools(discoveredTools);
570
578
  const auditedToolCount = tools.length;
571
579
  const detectedButUnemitted = detectedButUnemittedTools(
@@ -603,6 +611,9 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
603
611
  `skipping interactive auth ${skipped.name}: ${skipped.reason}; receipt=${skipped.verificationStatus}`,
604
612
  );
605
613
  }
614
+ for (const name of skippedIrreversible) {
615
+ log(`skipping irreversible workflow ${name}: live audit is intentionally disabled`);
616
+ }
606
617
 
607
618
  // Parameters that shipped live-unverified at compile time (Fix D). Tell the
608
619
  // auditor to probe them especially — these are the most likely to be broken
@@ -648,7 +659,7 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
648
659
  tokenDeps,
649
660
  });
650
661
  if (auditedToolCount === 0) {
651
- log('all discovered tools require human-interactive authentication; auditor not spawned');
662
+ log('all discovered tools are excluded from live audit; auditor not spawned');
652
663
  }
653
664
  recordLlmUsageSpan(
654
665
  'audit.llm_usage',
@@ -747,6 +758,7 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
747
758
  'imprint.audit.detected_tool_count': detectedToolCount,
748
759
  'imprint.audit.detected_but_unemitted_count': detectedButUnemitted.length,
749
760
  'imprint.audit.skipped_interactive_auth_count': skippedInteractiveAuth.length,
761
+ 'imprint.audit.skipped_irreversible_count': skippedIrreversible.length,
750
762
  'imprint.audit.verdict': score.verdict,
751
763
  'imprint.audit.unverified_and_ungradeable_count': unverifiedAndUngradeable.length,
752
764
  'imprint.audit.missing_tool_count': missingToolNames.length,
@@ -766,6 +778,7 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
766
778
  detectedToolCount,
767
779
  detectedButUnemittedTools: detectedButUnemitted,
768
780
  skippedInteractiveAuth,
781
+ skippedIrreversible,
769
782
  ungradeableTools: ungradeableNames,
770
783
  /** Connected tools without an invocation record. A partial audit
771
784
  * cannot pass merely because the auditor named an uncalled tool. */
@@ -808,6 +821,7 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
808
821
  report: drive.report,
809
822
  auditedToolCount,
810
823
  skippedInteractiveAuth,
824
+ skippedIrreversible,
811
825
  detectedToolCount,
812
826
  detectedButUnemittedTools: detectedButUnemitted,
813
827
  });
@@ -1124,19 +1138,19 @@ export function buildUnverifiedParamNote(
1124
1138
  .map((u) => `${u.tool}(${u.params.join(', ')})`)
1125
1139
  .join(
1126
1140
  '; ',
1127
- )}. Give each one a \`parameters\` verdict (works / no_op / broken / untestable) like any other — do not let an unverified parameter pass without a differential test. Only skip probing when the tool is genuinely state-changing / irreversible, or when repeated paced attempts for that parameter stay blocked by infrastructure. A bot-defended idempotent read is still testable after the warm browser session is established.`;
1141
+ )}. Give each one a \`parameters\` verdict (works / no_op / broken / untestable) like any other — do not let an unverified parameter pass without a differential test. Only skip probing when the connected tool is state-changing, or when repeated paced attempts for that parameter stay blocked by infrastructure. Irreversible tools are excluded before this prompt is built. A bot-defended idempotent read is still testable after the warm browser session is established.`;
1128
1142
  }
1129
1143
 
1130
1144
  export function buildAuditInitialPrompt(opts: DriveAuditOptions, unverifiedNote: string): string {
1131
1145
  const nonInteractiveAuthNames = opts.nonInteractiveAuthNames ?? [];
1132
1146
  const authNote = nonInteractiveAuthNames.length
1133
- ? `\n\nThe following authentication tool(s) are eligible because every action completes without a human pause: ${nonInteractiveAuthNames.join(', ')}. Call each exactly once with one safe baseline. Never differentially probe or retry authentication actions. If any DATA tool returns AUTH_EXPIRED, classify that invocation as infra/inconclusive evidence; do not call an authentication tool in response.`
1147
+ ? `\n\nEligible unattended authentication tool(s): ${nonInteractiveAuthNames.join(', ')}. Apply the unattended authentication rules only to these tools, with at most one recovery authentication call total across this audit.`
1134
1148
  : '\n\nNo authentication tool is eligible in this audit. If a DATA tool returns AUTH_EXPIRED, classify that invocation as infra/inconclusive evidence; do not attempt to authenticate.';
1135
1149
  return `Audit every MCP tool connected to you for the site "${opts.site}".
1136
1150
 
1137
1151
  There are ${opts.toolNames.length} connected tool(s). For each one: read its description and input schema, invoke it with a realistic parameter set, judge the result, and classify each invocation as correct | tool_broken | infra | bad_params per your system prompt.
1138
1152
 
1139
- Parameter coverage is mandatory for idempotent read tools. A cold cdp-replay/browser-backed first call may be slow because it launches or warms a trusted browser session, but later calls usually reuse that warm session and are much cheaper. Do NOT use the first call's cold-start latency as a reason to skip parameter probes. For search/list/calendar/lookup/quote/read tools, keep making paced sequential differential calls until each advertised parameter has a verdict, unless the same parameter remains blocked by infrastructure after repeated paced retries. For genuinely state-changing or irreversible tools (book/pay/send/cancel/delete/order), make only the safe baseline call and mark parameters untestable with that state-changing reason.
1153
+ Parameter coverage is mandatory for idempotent read tools. A cold cdp-replay/browser-backed first call may be slow because it launches or warms a trusted browser session, but later calls usually reuse that warm session and are much cheaper. Do NOT use the first call's cold-start latency as a reason to skip parameter probes. For search/list/calendar/lookup/quote/read tools, keep making paced sequential differential calls until each advertised parameter has a verdict, unless the same parameter remains blocked by infrastructure after repeated paced retries. For connected state-changing but reversible tools, make only one safe baseline call and mark parameters untestable with that state-changing reason. Irreversible workflows are never connected to this audit.
1140
1154
 
1141
1155
  Promo/coupon/voucher/discount-code parameters require a known valid code to prove a visible effect. If neither the schema nor another tool output gives you a valid code, classify that parameter as untestable; do not invent a random code and call an unchanged response no_op.
1142
1156
 
@@ -1592,6 +1606,7 @@ function printSummary(
1592
1606
  report: AuditReport;
1593
1607
  auditedToolCount: number;
1594
1608
  skippedInteractiveAuth: SkippedInteractiveAuth[];
1609
+ skippedIrreversible: string[];
1595
1610
  detectedToolCount: number;
1596
1611
  detectedButUnemittedTools: DetectedButUnemittedTool[];
1597
1612
  },
@@ -1614,6 +1629,9 @@ function printSummary(
1614
1629
  `[imprint] skipped interactive auth ${skipped.name} — ${skipped.reason}; verification receipt: ${skipped.verificationStatus}`,
1615
1630
  );
1616
1631
  }
1632
+ for (const name of extra.skippedIrreversible) {
1633
+ console.log(`[imprint] skipped irreversible workflow ${name} — live audit disabled`);
1634
+ }
1617
1635
  if (extra.detectedButUnemittedTools.length > 0) {
1618
1636
  console.log(
1619
1637
  `[imprint] ${extra.detectedButUnemittedTools.length}/${extra.detectedToolCount} detected tool(s) were not emitted and are excluded from scoring: ${extra.detectedButUnemittedTools.map((tool) => tool.name).join(', ')}`,
@@ -27,6 +27,7 @@ import {
27
27
  AUTH_COMPILE_TOOL_NAMES,
28
28
  AUTH_VERIFICATION_ATTEMPT_SENTINEL,
29
29
  authExternalVerification,
30
+ authLivePreflightFailures,
30
31
  authWorkflowHash,
31
32
  authWorkflowPreflightFailures,
32
33
  buildAuthCompileTools,
@@ -85,20 +86,21 @@ function buildAuthInitialMessage(opts: {
85
86
  authToolPlan: NonNullable<AuthToolPlan>;
86
87
  }): string {
87
88
  const { site, toolName, toolDir, authToolPlan } = opts;
88
- const headerCaptures = (authToolPlan.captures ?? []).filter((c) => {
89
+ const durableCaptures = (authToolPlan.captures ?? []).filter((c) => {
89
90
  const u = (c.usedAs ?? '').toLowerCase();
90
- // Cookies persist automatically — only surface NON-cookie header contracts.
91
- return u.startsWith('header:') && u !== 'header:cookie' && u !== 'header:set-cookie';
91
+ // Cookies persist automatically. Every other downstream transport needs a
92
+ // durable credential contract, including JSON/form body fields.
93
+ return u.length > 0 && u !== 'header:cookie' && u !== 'header:set-cookie';
92
94
  });
93
95
  const durableCaptureNote =
94
- headerCaptures.length > 0
95
- ? `\n- durable credential contracts (data tools consume these as \${credential.<name>}): ${headerCaptures
96
+ durableCaptures.length > 0
97
+ ? `\n- durable credential contracts (data tools consume these as \${credential.<name>}): ${durableCaptures
96
98
  .map(
97
99
  (c) => `${c.name} (used as ${c.usedAs}; seed source ${c.source}, locator ${c.locator})`,
98
100
  )
99
101
  .join(
100
102
  '; ',
101
- )}\n → Capture each value on the recording-grounded producing request and include its name in authConfig.persist. The seed source/locator is only a hint.`
103
+ )}\n → These names are downstream credential interface names, not required internal capture names. Capture each value on the recording-grounded producing request and include the interface name in authConfig.persist. If you choose a different capture name, declare authConfig.persistBindings[interfaceName] = captureName. The seed source/locator is only a hint.`
102
104
  : '';
103
105
  return `A new auth compile task is starting.
104
106
 
@@ -456,10 +458,12 @@ async function runAuthSegmentLoop(opts: AuthSegmentLoopOptions): Promise<Compile
456
458
  const checkpointStartedAt = Date.now();
457
459
  try {
458
460
  if (cp.kind === 'run_verification') {
459
- const preflightFailures = authWorkflowPreflightFailures(
461
+ const preflightFailures = await authLivePreflightFailures(
460
462
  opts.toolDir,
461
463
  opts.session,
462
464
  opts.authToolPlan.credentialNames,
465
+ [...opts.authToolPlan.credentialRequestSeqs, ...opts.authToolPlan.authRequestSeqs],
466
+ opts.sessionPath,
463
467
  );
464
468
  if (Date.now() >= activeDeadlineMs) {
465
469
  resultMsg =
@@ -10,17 +10,21 @@ import { join as pathJoin } from 'node:path';
10
10
  import type { AgentTool } from './agent.ts';
11
11
  import {
12
12
  type CompileToolContext,
13
+ bodyEncodingContractFailures,
13
14
  buildReadFileTool,
14
15
  buildReadRequestTool,
15
16
  buildReadResponseBodyTool,
16
17
  buildReadSessionSummaryTool,
17
- buildRunBashTool,
18
+ buildRunTestsTool,
18
19
  buildSearchRequestsTool,
19
20
  buildWriteFileTool,
21
+ irreversibleProvenanceFailures,
22
+ requestEncodingTestContractFailures,
20
23
  } from './compile-tools.ts';
24
+ import { isIrreversibleRequest, workflowHasIrreversibleEffect } from './effects.ts';
21
25
  import { recordedRequestMatchesWorkflow } from './recording-request.ts';
22
- import { captureHeader, captureValueMatches, jsonpath } from './request-capture.ts';
23
- import { type Session, type Workflow, WorkflowSchema } from './types.ts';
26
+ import { captureHeader, captureValueMatches, resolveJsonCapture } from './request-capture.ts';
27
+ import { type Session, type Workflow, WorkflowSchema, persistedCaptureName } from './types.ts';
24
28
 
25
29
  type TeachCredentials = { site: string; values: Record<string, string> };
26
30
 
@@ -40,7 +44,7 @@ export const AUTH_COMPILE_TOOL_NAMES = [
40
44
  'read_response_body',
41
45
  'write_file',
42
46
  'read_file',
43
- 'run_bash',
47
+ 'run_tests',
44
48
  'run_verification',
45
49
  'inspect_verification_page',
46
50
  'prompt_user',
@@ -50,7 +54,7 @@ export const AUTH_COMPILE_TOOL_NAMES = [
50
54
  export function buildAuthCompileTools(
51
55
  session: Session,
52
56
  toolDir: string,
53
- _sessionPath: string,
57
+ sessionPath: string,
54
58
  teachCredentials: TeachCredentials,
55
59
  ): AgentTool[] {
56
60
  const context: CompileToolContext = { teachCredentials };
@@ -61,7 +65,19 @@ export function buildAuthCompileTools(
61
65
  buildReadResponseBodyTool(session),
62
66
  buildWriteFileTool(toolDir),
63
67
  buildReadFileTool(toolDir),
64
- buildRunBashTool(toolDir),
68
+ buildRunTestsTool(
69
+ toolDir,
70
+ sessionPath,
71
+ {
72
+ // Auth request/capture tests are offline and use synthetic credentials.
73
+ // Explicitly mask any inherited teach payload so agent-authored test
74
+ // output cannot expose real credentials to the compile model.
75
+ IMPRINT_TEACH_CREDENTIALS: '',
76
+ },
77
+ {
78
+ networkDisabled: session.requests.some(isIrreversibleRequest),
79
+ },
80
+ ),
65
81
  ];
66
82
  }
67
83
 
@@ -75,9 +91,57 @@ export function authWorkflowPreflightFailures(
75
91
  return parseWorkflow(workflowPath, requiredCredentialNames, session).failures;
76
92
  }
77
93
 
94
+ export async function authLivePreflightFailures(
95
+ toolDir: string,
96
+ session?: Session,
97
+ requiredCredentialNames: readonly string[] = [],
98
+ authRequestSeqs: readonly number[] = [],
99
+ sessionPath = '',
100
+ ): Promise<string[]> {
101
+ const failures = authWorkflowPreflightFailures(toolDir, session, requiredCredentialNames);
102
+ if (failures.length > 0) return failures;
103
+ const parsed = parseWorkflow(
104
+ pathJoin(toolDir, 'workflow.json'),
105
+ requiredCredentialNames,
106
+ session,
107
+ );
108
+ if (!parsed.workflow) return parsed.failures;
109
+ const requestTestPath = pathJoin(toolDir, 'request.test.ts');
110
+ failures.push(
111
+ ...bodyEncodingContractFailures(parsed.workflow),
112
+ ...requestEncodingTestContractFailures(
113
+ parsed.workflow,
114
+ existsSync(requestTestPath) ? readFileSync(requestTestPath, 'utf8') : undefined,
115
+ ),
116
+ ...persistedCaptureTestContractFailures(
117
+ parsed.workflow,
118
+ existsSync(requestTestPath) ? readFileSync(requestTestPath, 'utf8') : undefined,
119
+ ),
120
+ );
121
+ if (session) {
122
+ failures.push(
123
+ ...irreversibleProvenanceFailures(session, parsed.workflow, {
124
+ candidateRequestSeqs: [...authRequestSeqs],
125
+ }),
126
+ );
127
+ }
128
+ if (workflowHasIrreversibleEffect(parsed.workflow)) {
129
+ failures.push('auth live verification is disabled for workflows with irreversible requests');
130
+ }
131
+ if (failures.length === 0 && existsSync(requestTestPath)) {
132
+ const testResult = await buildRunTestsTool(toolDir, sessionPath, undefined, {
133
+ networkDisabled: workflowHasIrreversibleEffect(parsed.workflow),
134
+ }).handler({});
135
+ if (testResult.isError) {
136
+ failures.push(`agent-authored request tests failed: ${testResult.result}`);
137
+ }
138
+ }
139
+ return failures;
140
+ }
141
+
78
142
  export function authExternalVerification(
79
143
  toolDir: string,
80
- requiredSessionCaptures: Array<{ name: string; usedAs?: string }> = [],
144
+ requiredDurableCredentials: Array<{ name: string; usedAs?: string }> = [],
81
145
  options: { requireLiveAttempt?: boolean; requiredCredentialNames?: readonly string[] } = {},
82
146
  ): string[] {
83
147
  const workflowPath = pathJoin(toolDir, 'workflow.json');
@@ -87,12 +151,27 @@ export function authExternalVerification(
87
151
  if (!parsed.workflow) return parsed.failures;
88
152
  const failures = [...parsed.failures];
89
153
  const workflow = parsed.workflow;
154
+ failures.push(...bodyEncodingContractFailures(workflow));
155
+ failures.push(
156
+ ...requestEncodingTestContractFailures(
157
+ workflow,
158
+ existsSync(pathJoin(toolDir, 'request.test.ts'))
159
+ ? readFileSync(pathJoin(toolDir, 'request.test.ts'), 'utf8')
160
+ : undefined,
161
+ ),
162
+ ...persistedCaptureTestContractFailures(
163
+ workflow,
164
+ existsSync(pathJoin(toolDir, 'request.test.ts'))
165
+ ? readFileSync(pathJoin(toolDir, 'request.test.ts'), 'utf8')
166
+ : undefined,
167
+ ),
168
+ );
90
169
  const persisted = new Set(workflow.authConfig?.persist ?? []);
91
170
 
92
- const missingContracts = requiredSessionCaptures.filter((capture) => {
171
+ const missingContracts = requiredDurableCredentials.filter((capture) => {
93
172
  const target = (capture.usedAs ?? '').toLowerCase();
94
173
  return (
95
- target.startsWith('header:') &&
174
+ target.length > 0 &&
96
175
  target !== 'header:cookie' &&
97
176
  target !== 'header:set-cookie' &&
98
177
  !persisted.has(capture.name)
@@ -100,7 +179,7 @@ export function authExternalVerification(
100
179
  });
101
180
  if (missingContracts.length > 0) {
102
181
  failures.push(
103
- `authConfig.persist must include downstream credential capture(s): ${missingContracts
182
+ `authConfig.persist must include downstream credential interface(s): ${missingContracts
104
183
  .map((capture) => capture.name)
105
184
  .join(', ')}`,
106
185
  );
@@ -112,6 +191,25 @@ export function authExternalVerification(
112
191
  return failures;
113
192
  }
114
193
 
194
+ function persistedCaptureTestContractFailures(
195
+ workflow: Workflow,
196
+ source: string | undefined,
197
+ ): string[] {
198
+ const persisted = workflow.authConfig?.persist ?? [];
199
+ if (persisted.length === 0) return [];
200
+ if (source === undefined) {
201
+ return [
202
+ 'request.test.ts is required to prove persisted auth captures against their exact recorded response fields',
203
+ ];
204
+ }
205
+ return persisted
206
+ .filter((name) => !source.includes(`persisted-capture:${name}`))
207
+ .map(
208
+ (name) =>
209
+ `request.test.ts must include a passing "persisted-capture:${name}" test that independently decodes the recorded producer response and asserts the compiled capture equals that exact structured value`,
210
+ );
211
+ }
212
+
115
213
  function parseWorkflow(
116
214
  path: string,
117
215
  requiredCredentialNames: readonly string[] = [],
@@ -274,17 +372,28 @@ function authProgramFailures(
274
372
  }
275
373
 
276
374
  if (!hasSuccess) failures.push('authConfig.actions has no success outcome');
375
+ const persistedCredentials = new Set(config.persist);
376
+ for (const credentialName of Object.keys(config.persistBindings ?? {})) {
377
+ if (!persistedCredentials.has(credentialName)) {
378
+ failures.push(
379
+ `authConfig.persistBindings declares unused credential ${JSON.stringify(credentialName)}`,
380
+ );
381
+ }
382
+ }
277
383
  for (const name of config.persist) {
278
- if (!captures.has(name)) {
279
- failures.push(`authConfig.persist references unknown capture ${JSON.stringify(name)}`);
384
+ const captureName = persistedCaptureName(config, name);
385
+ if (!captures.has(captureName)) {
386
+ failures.push(
387
+ `authConfig.persist credential ${JSON.stringify(name)} references unknown capture ${JSON.stringify(captureName)}`,
388
+ );
280
389
  continue;
281
390
  }
282
- const owner = captureOwners.get(name);
391
+ const owner = captureOwners.get(captureName);
283
392
  if (!owner) continue;
284
393
  const { request, capture } = owner;
285
394
  if (request.recordingRequestSeq === undefined) {
286
395
  failures.push(
287
- `persisted capture ${JSON.stringify(name)} must declare recordingRequestSeq on its producing request`,
396
+ `persisted capture ${JSON.stringify(captureName)} must declare recordingRequestSeq on its producing request`,
288
397
  );
289
398
  } else if (session) {
290
399
  const recorded = session.requests.find(
@@ -292,16 +401,16 @@ function authProgramFailures(
292
401
  );
293
402
  if (!recorded) {
294
403
  failures.push(
295
- `persisted capture ${JSON.stringify(name)} references missing recorded request seq ${request.recordingRequestSeq}`,
404
+ `persisted capture ${JSON.stringify(captureName)} references missing recorded request seq ${request.recordingRequestSeq}`,
296
405
  );
297
406
  } else if (!recordedRequestMatchesWorkflow(recorded, request)) {
298
407
  failures.push(
299
- `persisted capture ${JSON.stringify(name)} recordingRequestSeq ${request.recordingRequestSeq} does not match its workflow request`,
408
+ `persisted capture ${JSON.stringify(captureName)} recordingRequestSeq ${request.recordingRequestSeq} does not match its workflow request`,
300
409
  );
301
410
  } else {
302
411
  if (!recordedResponseProducesCapture(recorded, capture)) {
303
412
  failures.push(
304
- `persisted capture ${JSON.stringify(name)} is not produced by recorded request seq ${request.recordingRequestSeq}`,
413
+ `persisted capture ${JSON.stringify(captureName)} is not produced by recorded request seq ${request.recordingRequestSeq}`,
305
414
  );
306
415
  }
307
416
  }
@@ -323,7 +432,7 @@ function recordedResponseProducesCapture(
323
432
  let value: unknown;
324
433
  if (capture.source === 'json') {
325
434
  try {
326
- value = jsonpath(JSON.parse(body), capture.path);
435
+ value = resolveJsonCapture(JSON.parse(body), capture.path, capture.decodeJsonPath);
327
436
  } catch {
328
437
  return false;
329
438
  }
@@ -59,7 +59,7 @@ export class AuthVerifier {
59
59
  async runAction(
60
60
  action: string,
61
61
  parameters: Record<string, string | number | boolean> = {},
62
- options: { freshSession?: boolean; cleanSession?: boolean } = {},
62
+ options: { freshSession?: boolean; cleanSession?: boolean; signal?: AbortSignal } = {},
63
63
  ): Promise<AuthActionResult> {
64
64
  if (options.freshSession || options.cleanSession) await this.reset();
65
65
  this.rememberSensitiveValues(parameters);
@@ -74,6 +74,7 @@ export class AuthVerifier {
74
74
  cdpPool: this.cdpPool,
75
75
  forceBackend: 'cdp-replay',
76
76
  initialState: this.continuation,
77
+ signal: options.signal,
77
78
  });
78
79
  const durationMs = Date.now() - startedAt;
79
80
  const result = ladder.result;