imprint-mcp 0.6.2 → 0.6.4

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.
@@ -35,6 +35,10 @@ import {
35
35
  type SharedModuleManifestEntry,
36
36
  resolvePlanSliceFromFile,
37
37
  } from './build-plan.ts';
38
+ import {
39
+ advanceIncompleteSemanticVerificationRuns,
40
+ advanceSemanticVerificationCycle,
41
+ } from './compile-agent-types.ts';
38
42
  import {
39
43
  applyLiveVerification,
40
44
  applyParamVerification,
@@ -42,10 +46,12 @@ import {
42
46
  externalVerification,
43
47
  } from './compile-tools.ts';
44
48
  import {
49
+ isInfrastructureOnlyInconclusiveReport,
45
50
  mergeSemanticParamVerification,
46
51
  runLiveSemanticVerification,
47
52
  semanticVerificationFailures,
48
53
  } from './live-verifier.ts';
54
+ import { DEFAULT_VERIFICATION_PROVIDER } from './llm.ts';
49
55
  import type { ProviderName } from './llm.ts';
50
56
  import { loadJsonFile } from './load-json.ts';
51
57
  import { createLog } from './log.ts';
@@ -210,22 +216,45 @@ export function compileArtifactFingerprint(toolDir: string): string {
210
216
  }
211
217
 
212
218
  export function canAcceptInconclusiveDecision(opts: {
219
+ semanticVerificationCycles: number;
220
+ maxVerificationCycles: number;
221
+ completedReview: boolean;
222
+ infrastructureOnly: boolean;
213
223
  pendingFingerprint?: string;
214
224
  currentFingerprint: string;
215
225
  acceptInconclusive?: boolean;
216
226
  inconclusiveReason?: string;
217
227
  }): boolean {
218
228
  return (
229
+ opts.completedReview &&
230
+ opts.infrastructureOnly &&
231
+ opts.semanticVerificationCycles >= opts.maxVerificationCycles &&
219
232
  opts.acceptInconclusive === true &&
220
233
  Boolean(opts.inconclusiveReason?.trim()) &&
221
234
  opts.pendingFingerprint === opts.currentFingerprint
222
235
  );
223
236
  }
224
237
 
238
+ export function inconclusiveDecisionAtSemanticCap(opts: {
239
+ semanticVerificationCycles: number;
240
+ maxVerificationCycles: number;
241
+ pendingFingerprint?: string;
242
+ currentFingerprint: string;
243
+ }): 'continue-verification' | 'await-compiler-decision' | 'fail-artifact-changed' {
244
+ if (!opts.pendingFingerprint || opts.semanticVerificationCycles < opts.maxVerificationCycles) {
245
+ return 'continue-verification';
246
+ }
247
+ return opts.pendingFingerprint === opts.currentFingerprint
248
+ ? 'await-compiler-decision'
249
+ : 'fail-artifact-changed';
250
+ }
251
+
225
252
  export async function runCompileMcpServer(opts: RunCompileMcpServerOptions): Promise<void> {
226
253
  const isAuthMode = !!opts.authToolPlan;
227
254
  const maxVerificationCycles = opts.maxVerificationCycles ?? 5;
255
+ const maxIncompleteVerifierRuns = 2;
228
256
  let pendingInconclusive: PendingInconclusiveDecision | undefined;
257
+ let incompleteVerifierRuns = 0;
229
258
 
230
259
  // Load + auto-redact the session, exactly as compile-agent.ts does.
231
260
  let session: Session = loadJsonFile(
@@ -412,7 +441,7 @@ export async function runCompileMcpServer(opts: RunCompileMcpServerOptions): Pro
412
441
  ]
413
442
  : [];
414
443
 
415
- let verificationFailures = 0;
444
+ let semanticVerificationCycles = 0;
416
445
  /** One pending checkpoint per segment — refuse a second so the orchestrator
417
446
  * acts on exactly one request. Cleared implicitly: each segment is a fresh
418
447
  * process with the sentinel removed before spawn. */
@@ -487,15 +516,15 @@ export async function runCompileMcpServer(opts: RunCompileMcpServerOptions): Pro
487
516
  };
488
517
  }
489
518
 
490
- verificationFailures++;
491
- log(`auth verification failed (cycle ${verificationFailures})`);
519
+ semanticVerificationCycles++;
520
+ log(`auth verification failed (cycle ${semanticVerificationCycles})`);
492
521
 
493
522
  return {
494
523
  isError: true,
495
524
  content: [
496
525
  {
497
526
  type: 'text',
498
- text: `You called done but verification failed (cycle ${verificationFailures}):
527
+ text: `You called done but verification failed (cycle ${semanticVerificationCycles}):
499
528
 
500
529
  ${failures.map((f) => `- ${f}`).join('\n')}
501
530
 
@@ -507,6 +536,8 @@ Fix the issues in workflow.json, re-test with run_verification, and call done ag
507
536
 
508
537
  const finishVerification = beginCompileVerification(opts.toolDir);
509
538
  try {
539
+ let semanticReviewCompleted = false;
540
+ let semanticReviewAttempted = false;
510
541
  const { failures, warnings, paramVerification, integrationEvidence } =
511
542
  await externalVerification(opts.toolDir, session, opts.sessionPath, {
512
543
  expectedToolName: opts.candidate?.toolName,
@@ -536,6 +567,12 @@ Fix the issues in workflow.json, re-test with run_verification, and call done ag
536
567
  if (
537
568
  pendingInconclusive &&
538
569
  canAcceptInconclusiveDecision({
570
+ semanticVerificationCycles,
571
+ maxVerificationCycles,
572
+ completedReview: pendingInconclusive.report.completedReview,
573
+ infrastructureOnly: isInfrastructureOnlyInconclusiveReport(
574
+ pendingInconclusive.report.report,
575
+ ),
539
576
  pendingFingerprint: pendingInconclusive.artifactFingerprint,
540
577
  currentFingerprint,
541
578
  acceptInconclusive: doneArgs.accept_inconclusive,
@@ -601,89 +638,154 @@ Fix the issues in workflow.json, re-test with run_verification, and call done ag
601
638
  };
602
639
  }
603
640
 
604
- if (
605
- pendingInconclusive &&
606
- pendingInconclusive.artifactFingerprint !== currentFingerprint
607
- ) {
608
- log(
609
- 'compile artifacts changed after inconclusive review; running verification again',
610
- );
611
- pendingInconclusive = undefined;
612
- }
613
- const semantic = await runLiveSemanticVerification({
614
- provider: opts.provider,
615
- toolDir: opts.toolDir,
616
- evidence: integrationEvidence,
641
+ const capDecision = inconclusiveDecisionAtSemanticCap({
642
+ semanticVerificationCycles,
643
+ maxVerificationCycles,
644
+ pendingFingerprint: pendingInconclusive?.artifactFingerprint,
645
+ currentFingerprint,
617
646
  });
618
- if (semantic.report.status === 'inconclusive') {
619
- pendingInconclusive = {
620
- artifactFingerprint: compileArtifactFingerprint(opts.toolDir),
621
- report: semantic,
622
- };
623
- } else {
647
+ if (capDecision === 'fail-artifact-changed') {
624
648
  pendingInconclusive = undefined;
625
- }
626
- const semanticFailures = semanticVerificationFailures(semantic.report);
627
- failures.push(...semanticFailures);
628
- if (semantic.report.status === 'approved_with_gaps') {
629
- warnings.push(
630
- `independent semantic verification approved with gaps: ${semantic.report.gaps.join('; ')}`,
649
+ failures.push(
650
+ 'compile artifacts changed after the final allowed inconclusive semantic review; refusing to run an additional review beyond the semantic cycle cap',
631
651
  );
632
- }
633
- if (semanticFailures.length === 0) {
634
- const semanticParams = mergeSemanticParamVerification(
635
- paramVerification,
636
- semantic.report,
652
+ } else if (capDecision === 'await-compiler-decision') {
653
+ failures.push(
654
+ 'semantic review limit reached with an unchanged infrastructure-only inconclusive result; explicitly accept the unverified artifact with reasoning or revise it',
637
655
  );
638
- // Only the independent semantic agent can mint liveVerified=true.
639
- applyLiveVerification(opts.toolDir, undefined);
640
- const paramWarnings = applyParamVerification(opts.toolDir, semanticParams);
641
- // liveVerified/parameter annotations are metadata-only, but they
642
- // change the hash-strict workflow cache key. Keep the verifier's
643
- // already-proven backend preference current without probing again.
644
- rebindExistingBackendsCacheToWorkflow(opts.toolDir);
645
- if (paramWarnings.length > 0) {
646
- log(`parameter verification:\n${paramWarnings.join('\n')}`);
656
+ } else {
657
+ if (
658
+ pendingInconclusive &&
659
+ pendingInconclusive.artifactFingerprint !== currentFingerprint
660
+ ) {
661
+ log(
662
+ 'compile artifacts changed after inconclusive review; running verification again',
663
+ );
664
+ pendingInconclusive = undefined;
647
665
  }
648
- const allWarnings = [...warnings, ...paramWarnings];
649
- const sentinel = pathJoin(opts.toolDir, DONE_SENTINEL);
650
- writeFileSync(
651
- sentinel,
652
- JSON.stringify(
653
- {
654
- summary,
655
- verification: 'passed',
656
- semanticVerification: {
657
- status: semantic.report.status,
658
- provider: semantic.provider,
659
- model: semantic.model,
660
- attempts: semantic.attempts,
661
- evidenceArtifact: semantic.report.evidenceArtifact,
662
- logArtifact: semantic.report.logArtifact,
666
+ semanticReviewAttempted = true;
667
+ const semantic = await runLiveSemanticVerification({
668
+ provider: DEFAULT_VERIFICATION_PROVIDER,
669
+ toolDir: opts.toolDir,
670
+ evidence: integrationEvidence,
671
+ });
672
+ semanticReviewCompleted = semantic.completedReview;
673
+ if (semantic.report.status === 'inconclusive') {
674
+ pendingInconclusive = {
675
+ artifactFingerprint: compileArtifactFingerprint(opts.toolDir),
676
+ report: semantic,
677
+ };
678
+ } else {
679
+ pendingInconclusive = undefined;
680
+ }
681
+ const semanticFailures = semanticVerificationFailures(semantic.report);
682
+ failures.push(...semanticFailures);
683
+ if (semantic.report.status === 'approved_with_gaps') {
684
+ warnings.push(
685
+ `independent semantic verification approved with gaps: ${semantic.report.gaps.join('; ')}`,
686
+ );
687
+ }
688
+ if (semanticFailures.length === 0) {
689
+ const semanticParams = mergeSemanticParamVerification(
690
+ paramVerification,
691
+ semantic.report,
692
+ );
693
+ // Only the independent semantic agent can mint liveVerified=true.
694
+ applyLiveVerification(opts.toolDir, undefined);
695
+ const paramWarnings = applyParamVerification(opts.toolDir, semanticParams);
696
+ // liveVerified/parameter annotations are metadata-only, but they
697
+ // change the hash-strict workflow cache key. Keep the verifier's
698
+ // already-proven backend preference current without probing again.
699
+ rebindExistingBackendsCacheToWorkflow(opts.toolDir);
700
+ if (paramWarnings.length > 0) {
701
+ log(`parameter verification:\n${paramWarnings.join('\n')}`);
702
+ }
703
+ const allWarnings = [...warnings, ...paramWarnings];
704
+ const sentinel = pathJoin(opts.toolDir, DONE_SENTINEL);
705
+ writeFileSync(
706
+ sentinel,
707
+ JSON.stringify(
708
+ {
709
+ summary,
710
+ verification: 'passed',
711
+ semanticVerification: {
712
+ status: semantic.report.status,
713
+ provider: semantic.provider,
714
+ model: semantic.model,
715
+ attempts: semantic.attempts,
716
+ evidenceArtifact: semantic.report.evidenceArtifact,
717
+ logArtifact: semantic.report.logArtifact,
718
+ },
719
+ warnings: allWarnings,
720
+ timestamp: Date.now(),
663
721
  },
664
- warnings: allWarnings,
665
- timestamp: Date.now(),
666
- },
667
- null,
668
- 2,
669
- ),
670
- 'utf8',
671
- );
672
- log(`verification passed; wrote ${sentinel}`);
673
- return {
674
- content: [
675
- {
676
- type: 'text',
677
- text: `DONE_VERIFIED — deterministic checks and independent live semantic verification passed (${semantic.report.status}). Evidence: ${semantic.report.evidenceArtifact ?? '.live-verification-evidence.json'}. Verifier log: ${semantic.report.logArtifact ?? '.live-verifier-log.jsonl'}. The orchestrator will exit shortly. Do not call any more tools.`,
678
- },
679
- ],
680
- };
722
+ null,
723
+ 2,
724
+ ),
725
+ 'utf8',
726
+ );
727
+ log(`verification passed; wrote ${sentinel}`);
728
+ return {
729
+ content: [
730
+ {
731
+ type: 'text',
732
+ text: `DONE_VERIFIED — deterministic checks and independent live semantic verification passed (${semantic.report.status}). Evidence: ${semantic.report.evidenceArtifact ?? '.live-verification-evidence.json'}. Verifier log: ${semantic.report.logArtifact ?? '.live-verifier-log.jsonl'}. The orchestrator will exit shortly. Do not call any more tools.`,
733
+ },
734
+ ],
735
+ };
736
+ }
681
737
  }
682
738
  }
683
739
  }
684
- verificationFailures++;
685
- log(`verification failed (cycle ${verificationFailures}/${maxVerificationCycles})`);
686
- if (verificationFailures >= maxVerificationCycles) {
740
+ semanticVerificationCycles = advanceSemanticVerificationCycle(
741
+ semanticVerificationCycles,
742
+ semanticReviewCompleted,
743
+ );
744
+ incompleteVerifierRuns = advanceIncompleteSemanticVerificationRuns(
745
+ incompleteVerifierRuns,
746
+ semanticReviewAttempted,
747
+ semanticReviewCompleted,
748
+ );
749
+ if (incompleteVerifierRuns >= maxIncompleteVerifierRuns) {
750
+ const sentinel = pathJoin(opts.toolDir, DONE_SENTINEL);
751
+ writeFileSync(
752
+ sentinel,
753
+ JSON.stringify(
754
+ {
755
+ summary,
756
+ verification: 'failed',
757
+ incompleteVerifierRuns,
758
+ failures,
759
+ warnings,
760
+ timestamp: Date.now(),
761
+ },
762
+ null,
763
+ 2,
764
+ ),
765
+ 'utf8',
766
+ );
767
+ return {
768
+ isError: true,
769
+ content: [
770
+ {
771
+ type: 'text',
772
+ text: `Independent semantic verifier failed to complete after ${maxIncompleteVerifierRuns} bounded runs. Final failures:\n${failures.map((failure) => `- ${failure}`).join('\n')}`,
773
+ },
774
+ ],
775
+ };
776
+ }
777
+ const failurePhase = semanticReviewCompleted
778
+ ? `semantic verification failed (cycle ${semanticVerificationCycles}/${maxVerificationCycles})`
779
+ : semanticReviewAttempted
780
+ ? 'semantic verifier did not complete a review (semantic cycle unchanged)'
781
+ : 'deterministic verification failed (semantic cycle unchanged)';
782
+ log(failurePhase);
783
+ const awaitingFinalInconclusiveDecision =
784
+ semanticVerificationCycles >= maxVerificationCycles && pendingInconclusive !== undefined;
785
+ if (
786
+ semanticVerificationCycles >= maxVerificationCycles &&
787
+ !awaitingFinalInconclusiveDecision
788
+ ) {
687
789
  const sentinel = pathJoin(opts.toolDir, DONE_SENTINEL);
688
790
  writeFileSync(
689
791
  sentinel,
@@ -691,7 +793,7 @@ Fix the issues in workflow.json, re-test with run_verification, and call done ag
691
793
  {
692
794
  summary,
693
795
  verification: 'failed',
694
- cycles: verificationFailures,
796
+ cycles: semanticVerificationCycles,
695
797
  failures,
696
798
  warnings,
697
799
  timestamp: Date.now(),
@@ -706,20 +808,23 @@ Fix the issues in workflow.json, re-test with run_verification, and call done ag
706
808
  content: [
707
809
  {
708
810
  type: 'text',
709
- text: `Verification failed after ${maxVerificationCycles} cycles. Giving up. Final failures:\n${failures.map((f) => `- ${f}`).join('\n')}`,
811
+ text: `Semantic verification failed after ${maxVerificationCycles} cycles. Giving up. Final failures:\n${failures.map((f) => `- ${f}`).join('\n')}`,
710
812
  },
711
813
  ],
712
814
  };
713
815
  }
714
816
 
715
- const continuationMessage = `You called done but verification failed (cycle ${verificationFailures}/${maxVerificationCycles}):
817
+ const continuationMessage = `You called done but ${failurePhase}:
716
818
 
717
819
  ${failures.map((f) => `- ${f}`).join('\n')}
718
820
 
719
821
  Resume your work. Read the files you wrote (workflow.json, parser.ts, parser.test.ts), fix the issues, re-run tests, and call done again when fixed.`;
720
- const compilerDecisionGuidance = `\n\nUse the verifier's semantic evidence, not a requirement for 100% of the original candidate inputs. If the core intent works but a secondary parameter cannot be supported from the recording and live evidence without guessing, remove that parameter and add workflow.limitations with omittedParameters and a specific reason. Close that omission over its dependencies: also remove dependent public inputs and narrow parameter/intent descriptions so callers cannot still enter the unsupported branch indirectly. Do not use a limitation to hide a broken core tool, and do not keep a known broken or ignored public input.`;
822
+ const compilerDecisionGuidance = `\n\nUse the verifier's semantic evidence, not a requirement for 100% of the original candidate inputs. If the core intent works but a secondary parameter cannot be supported from the recording and live evidence without guessing, remove that parameter and add workflow.limitations with omittedParameters and a specific reason. Treat emitsTokens as downstream compatibility targets, not automatic definitions of producer success: when useful core records lack an optional consumer token, preserve the records and supported input classes, document the missing output and affected consumers, and leave those consumers limited/unverified. Narrow an input class only when its core results are unusable or misleading. Remove claims that directly depend on an omitted feature, but do not erase valid producer results solely to make a downstream chain total. Do not use a limitation to hide a broken core tool, and do not keep a known broken or ignored public input.`;
721
823
  const inconclusiveDecisionGuidance = pendingInconclusive
722
- ? '\n\nThe live verifier was infrastructure-only inconclusive. Review its evidence and log. If they expose a tool defect, revise the artifacts and call done normally so verification reruns. If they expose no tool-level defect and the deterministic artifact should ship explicitly unverified, call done again without changing compile artifacts, set accept_inconclusive=true, and provide your concrete reasoning in inconclusive_reason. This compiler decision is persisted and does not mint liveVerified.'
824
+ ? semanticVerificationCycles >= maxVerificationCycles &&
825
+ pendingInconclusive.report.completedReview
826
+ ? '\n\nThe final allowed live semantic review was infrastructure-only inconclusive. Review its evidence and log. If they expose a tool defect, do not attempt to change the artifact after the review cap. If they expose no tool-level defect and the unchanged deterministic artifact should ship explicitly unverified, call done again without changing compile artifacts, set accept_inconclusive=true, and provide your concrete reasoning in inconclusive_reason. This compiler decision is persisted and does not mint liveVerified.'
827
+ : '\n\nThe live verifier was infrastructure-only inconclusive. Review its evidence and log. Fix any exposed tool defect, or call done normally to request another independent review. Explicit unverified acceptance is available only after the semantic review limit is reached.'
723
828
  : '';
724
829
  return {
725
830
  isError: true,
@@ -49,6 +49,9 @@ interface RunMcpServerOptions {
49
49
  name?: string;
50
50
  /** Server version. */
51
51
  version?: string;
52
+ /** Optional exact tool-name allowlist. Used by audit sessions to keep
53
+ * human-interactive authentication workflows out of headless MCP exposure. */
54
+ includeToolNames?: readonly string[];
52
55
  }
53
56
 
54
57
  interface ResolvedTool extends DiscoveredTool {
@@ -58,6 +61,17 @@ interface ResolvedTool extends DiscoveredTool {
58
61
  preferredOrder?: ConcreteBackend[];
59
62
  }
60
63
 
64
+ /** Exact allowlist selection used by bounded MCP sessions such as audit. An
65
+ * omitted allowlist preserves normal production behavior and exposes all tools. */
66
+ export function selectMcpTools<T extends { workflow: { toolName: string } }>(
67
+ tools: readonly T[],
68
+ includeToolNames?: readonly string[],
69
+ ): T[] {
70
+ if (!includeToolNames) return [...tools];
71
+ const includedNames = new Set(includeToolNames);
72
+ return tools.filter((tool) => includedNames.has(tool.workflow.toolName));
73
+ }
74
+
61
75
  /** Tool description shown to MCP clients. Includes the operator's
62
76
  * recorded narration when present — surprisingly load-bearing for the
63
77
  * LLM picking the right tool. */
@@ -437,7 +451,8 @@ function formatToolError(
437
451
 
438
452
  export async function runMcpServer(opts: RunMcpServerOptions): Promise<void> {
439
453
  const assetRoot = opts.assetRoot ?? imprintHomeDir();
440
- const discovered = await discoverTools(assetRoot, opts.site, '[imprint mcp]');
454
+ const discoveredAll = await discoverTools(assetRoot, opts.site, '[imprint mcp]');
455
+ const discovered = selectMcpTools(discoveredAll, opts.includeToolNames);
441
456
  const tools: ResolvedTool[] = discovered.map((t) => {
442
457
  const playbookPath = pathResolve(t.dir, 'playbook.yaml');
443
458
  const cacheStatus = loadBackendsCacheStatus(t.site, assetRoot, t.dir, {
@@ -26,6 +26,7 @@ import {
26
26
  BackendsCacheSchema,
27
27
  type ConcreteBackend,
28
28
  CronConfigSchema,
29
+ type ToolResult,
29
30
  WorkflowSchema,
30
31
  } from './types.ts';
31
32
  import { VERSION } from './version.ts';
@@ -120,20 +121,32 @@ export async function probeResolvedTool(
120
121
  log(`probing backends for ${tool.workflow.toolName}…`);
121
122
  log(` params: ${JSON.stringify(params)}`);
122
123
 
123
- // Try every backend (single-rung ladders) — operators want the full
124
- // matrix, not just the first that worked. cdp-replay is included so it
125
- // lands in preferredOrder when it works — without it, runtime always
126
- // falls through fetch-bootstrap (~30-60s) before reaching the spliced-in
127
- // cdp-replay rung, wasting time on every call.
124
+ // Probe the workflow's normal candidates first. Plain workflows keep the
125
+ // cheap fetch/stealth/playbook path; browser-backed API transports are only
126
+ // added if none of those candidates works.
128
127
  const stealthCache = new Map<string, StealthFetch>();
129
128
  const cdpPool = new Map<string, CdpBrowserFetch>();
130
129
  const allBackends = probeCandidateBackendsForWorkflow(tool.workflow);
131
130
  const results: BackendsCache['results'] = {};
132
131
  const working: BackendProbeCandidate[] = [];
133
132
  const preferredMaxMs = preferredBackendMaxMs();
133
+ const persistCurrentCache = (): BackendsCache => {
134
+ const cache: BackendsCache = {
135
+ probedAt: new Date().toISOString(),
136
+ imprintVersion: VERSION,
137
+ schemaVersion: 2,
138
+ workflowHash: workflowHash(tool.workflow),
139
+ capabilityHash: workflowCapabilityHash(tool.workflow),
140
+ preferredOrder: rankSuccessfulBackends(working),
141
+ results,
142
+ };
143
+ BackendsCacheSchema.parse(cache);
144
+ writeFileSync(outPath, `${JSON.stringify(cache, null, 2)}\n`);
145
+ return cache;
146
+ };
134
147
 
135
- try {
136
- for (const backend of allBackends) {
148
+ const probeCandidates = async (backends: ConcreteBackend[]): Promise<void> => {
149
+ for (const backend of backends) {
137
150
  log(`probing ${backend}…`);
138
151
  const t0 = Date.now();
139
152
  const { result, attempts } = await runWithLadder(
@@ -147,6 +160,14 @@ export async function probeResolvedTool(
147
160
  const durationMs = Date.now() - t0;
148
161
  const attempt = attempts[0];
149
162
 
163
+ const invariantFailure = backendInvariantProbeFailure(result);
164
+ if (invariantFailure) {
165
+ log(` ${backend}: workflow rejected the request before transport — stopping probe`);
166
+ throw new Error(
167
+ `Backend-independent workflow failure for ${tool.workflow.toolName}: ${invariantFailure}`,
168
+ );
169
+ }
170
+
150
171
  if (!attempt) {
151
172
  results[backend] = { outcome: 'skipped', detail: 'no attempt recorded' };
152
173
  continue;
@@ -189,6 +210,11 @@ export async function probeResolvedTool(
189
210
  ...(warm?.ok ? { warmDurationMs: warm.durationMs, rankingDurationMs } : {}),
190
211
  tooSlow,
191
212
  });
213
+ // Keep a valid preferred backend durable as soon as one succeeds. A
214
+ // later optional candidate may tarpit until the aggregate preparation
215
+ // deadline; losing this earlier success would force an unnecessary
216
+ // reprobe and leave the suite with no prepared backend.
217
+ persistCurrentCache();
192
218
  log(
193
219
  ` ${backend}: OK in ${durationMs}ms${warm?.ok ? ` (warm ${warm.durationMs}ms)` : ''}${tooSlow ? ' (cold slow)' : ''}`,
194
220
  );
@@ -212,6 +238,14 @@ export async function probeResolvedTool(
212
238
  log(` ${backend}: ${result.error} — ${result.message.slice(0, 100)}`);
213
239
  }
214
240
  }
241
+ };
242
+
243
+ try {
244
+ await probeCandidates(allBackends);
245
+ if (working.length === 0 && !allBackends.includes('cdp-replay')) {
246
+ log('default backends failed; probing browser-backed API fallbacks…');
247
+ await probeCandidates(['cdp-replay', 'fetch-bootstrap']);
248
+ }
215
249
  } finally {
216
250
  await closeProbeCdpPool(cdpPool);
217
251
  }
@@ -224,24 +258,24 @@ export async function probeResolvedTool(
224
258
  );
225
259
  }
226
260
 
227
- const preferredOrder = rankSuccessfulBackends(working);
228
- const cache: BackendsCache = {
229
- probedAt: new Date().toISOString(),
230
- imprintVersion: VERSION,
231
- schemaVersion: 2,
232
- workflowHash: workflowHash(tool.workflow),
233
- capabilityHash: workflowCapabilityHash(tool.workflow),
234
- preferredOrder,
235
- results,
236
- };
237
- BackendsCacheSchema.parse(cache); // catch schema drift early
238
-
239
- writeFileSync(outPath, `${JSON.stringify(cache, null, 2)}\n`);
240
- log(`wrote ${outPath} — preferred: ${preferredOrder.join(' → ')}`);
261
+ const cache = persistCurrentCache();
262
+ log(`wrote ${outPath} — preferred: ${cache.preferredOrder.join(' → ')}`);
241
263
 
242
264
  return { cache, outPath };
243
265
  }
244
266
 
267
+ /**
268
+ * Some generated-workflow failures happen before any HTTP request is sent.
269
+ * Retrying those through browser-backed transports is both expensive and
270
+ * misleading: a different transport cannot repair a deterministic transform.
271
+ * Keep remote HTTP BAD_RESPONSE failures probeable because bot defenses can
272
+ * return backend-specific 4xx responses.
273
+ */
274
+ export function backendInvariantProbeFailure(result: ToolResult): string | null {
275
+ if (result.ok || result.error !== 'BAD_RESPONSE') return null;
276
+ return /^request transform failed for request \d+:/i.test(result.message) ? result.message : null;
277
+ }
278
+
245
279
  export function rankSuccessfulBackends(candidates: BackendProbeCandidate[]): ConcreteBackend[] {
246
280
  return [...candidates]
247
281
  .sort((a, b) => {
@@ -302,9 +336,10 @@ function preferredBackendMaxMs(): number {
302
336
 
303
337
  function workflowNeedsBootstrap(workflow: ResolvedTool['workflow']): boolean {
304
338
  if (workflow.bootstrap) return true;
305
- return workflow.requests.some((r) =>
306
- (r.captures ?? []).some(
307
- (c) => c.capability === 'browser_bootstrap' || c.capability === 'stealth_bootstrap',
339
+ return workflow.requests.some((request) =>
340
+ (request.captures ?? []).some(
341
+ (capture) =>
342
+ capture.capability === 'browser_bootstrap' || capture.capability === 'stealth_bootstrap',
308
343
  ),
309
344
  );
310
345
  }
@@ -313,7 +348,7 @@ export function probeCandidateBackendsForWorkflow(
313
348
  workflow: ResolvedTool['workflow'],
314
349
  ): ConcreteBackend[] {
315
350
  return workflowNeedsBootstrap(workflow) || prefersCdpReplayFirst(workflow)
316
- ? ['fetch', 'fetch-bootstrap', 'cdp-replay', 'stealth-fetch', 'playbook']
351
+ ? ['fetch', 'cdp-replay', 'fetch-bootstrap', 'stealth-fetch', 'playbook']
317
352
  : ['fetch', 'stealth-fetch', 'playbook'];
318
353
  }
319
354
 
@@ -0,0 +1,31 @@
1
+ /** Bound per-row metadata for untrusted React Flight bodies below the byte cap. */
2
+ export const MAX_REACT_FLIGHT_ROWS = 16_384;
3
+
4
+ /** Bound aggregate parsed JSON width across an untrusted React Flight body. */
5
+ export const MAX_REACT_FLIGHT_JSON_NODES = 32_768;
6
+
7
+ /**
8
+ * Estimate JSON node count before JSON.parse allocates the object graph.
9
+ * For valid JSON, one root plus each container opener and comma is a
10
+ * conservative upper approximation of containers and values. Delimiters in
11
+ * strings are ignored, including escaped quotes.
12
+ */
13
+ export function boundedJsonNodeCount(json: string, limit: number): number | null {
14
+ if (limit < 1) return null;
15
+ let count = 1;
16
+ let inString = false;
17
+ for (let index = 0; index < json.length; index++) {
18
+ const char = json.charCodeAt(index);
19
+ if (inString) {
20
+ if (char === 0x5c) index++;
21
+ else if (char === 0x22) inString = false;
22
+ continue;
23
+ }
24
+ if (char === 0x22) {
25
+ inString = true;
26
+ } else if (char === 0x7b || char === 0x5b || char === 0x2c) {
27
+ if (++count > limit) return null;
28
+ }
29
+ }
30
+ return count;
31
+ }