imprint-mcp 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +2 -0
  2. package/package.json +4 -2
  3. package/prompts/audit-agent.md +4 -2
  4. package/prompts/auth-compile-agent.md +149 -158
  5. package/prompts/build-planning.md +14 -9
  6. package/prompts/compile-agent.md +46 -23
  7. package/prompts/live-verifier-agent.md +101 -0
  8. package/prompts/playbook-compilation.md +8 -1
  9. package/prompts/tool-candidate-detection.md +9 -29
  10. package/src/cli.ts +83 -1
  11. package/src/imprint/audit.ts +219 -20
  12. package/src/imprint/auth-compile-agent.ts +215 -155
  13. package/src/imprint/auth-compile-tools.ts +300 -118
  14. package/src/imprint/auth-verifier.ts +167 -180
  15. package/src/imprint/backend-cache.ts +18 -11
  16. package/src/imprint/backend-ladder.ts +225 -177
  17. package/src/imprint/build-plan.ts +3 -8
  18. package/src/imprint/cdp-browser-fetch.ts +930 -83
  19. package/src/imprint/claude-cli-compile.ts +46 -20
  20. package/src/imprint/codex-cli-compile.ts +436 -139
  21. package/src/imprint/compile-agent-types.ts +15 -10
  22. package/src/imprint/compile-agent.ts +88 -28
  23. package/src/imprint/compile-tools.ts +228 -39
  24. package/src/imprint/compile-verification.ts +187 -0
  25. package/src/imprint/compile.ts +71 -2
  26. package/src/imprint/credential-store.ts +57 -44
  27. package/src/imprint/emit.ts +3 -1
  28. package/src/imprint/freeform-redact.ts +27 -6
  29. package/src/imprint/live-verifier.ts +1665 -0
  30. package/src/imprint/llm.ts +22 -3
  31. package/src/imprint/login.ts +107 -25
  32. package/src/imprint/mcp-compile-server.ts +447 -114
  33. package/src/imprint/mcp-live-verifier-server.ts +359 -0
  34. package/src/imprint/mcp-server.ts +116 -35
  35. package/src/imprint/probe-backends.ts +88 -7
  36. package/src/imprint/recording-request.ts +36 -0
  37. package/src/imprint/request-capture.ts +40 -17
  38. package/src/imprint/runtime.ts +490 -337
  39. package/src/imprint/teach-state.ts +36 -2
  40. package/src/imprint/teach.ts +302 -146
  41. package/src/imprint/tool-candidates.ts +8 -10
  42. package/src/imprint/tool-loader.ts +12 -4
  43. package/src/imprint/types.ts +137 -68
@@ -1,234 +1,221 @@
1
1
  /**
2
- * AuthVerifier — the live "verification stage" for the agent-orchestrated 2FA
3
- * flow. It is the ONLY thing that fires a real login at the site (the compile
4
- * agent shapes artifacts from the recording and never logs in itself).
5
- *
6
- * The whole point is ONE persistent browser session across the two 2FA phases:
7
- * a per-instance `cdpPool` is passed to every `runWorkflowWithLadder` call, so
8
- * the cdp-replay rung reuses the same live Chrome page across phase 1 (send the
9
- * OTP / push) → user input → phase 2 (submit the OTP / poll). Launching a fresh
10
- * browser between phases would reset a server-side challenge or drop a
11
- * single-use in-page token, so the pool is the load-bearing piece.
12
- *
13
- * Lifecycle: the orchestrator (teach) creates one AuthVerifier per auth run,
14
- * calls `runPhase` as the agent requests verifications, and ALWAYS calls
15
- * `drain()` in a `finally` — the pool keeps a real Chrome alive across the
16
- * user-input wait and any cool-off, so it must be closed deterministically.
17
- *
18
- * General: nothing here is site- or channel-specific; every decision is driven
19
- * by the compiled `workflow.json` / `authConfig` and the ladder.
2
+ * Live auth verification. The compile agent defines the auth program; this
3
+ * class only preserves one browser session and continuation state while running
4
+ * the requested actions.
20
5
  */
21
6
 
22
7
  import { runWorkflowWithLadder } from './backend-ladder.ts';
23
- import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
8
+ import type { CdpBrowserFetch, CdpPageSnapshot } from './cdp-browser-fetch.ts';
9
+ import { redactFreeformText } from './freeform-redact.ts';
24
10
  import { createLog } from './log.ts';
25
11
  import type { CredentialStore } from './runtime.ts';
26
12
 
27
13
  const log = createLog('auth-verify');
28
14
 
29
- /** Test seam: the ladder runner AuthVerifier drives. Swappable so the budget /
30
- * challenge-counting logic can be unit-tested without a live browser. */
31
15
  type LadderRunner = typeof runWorkflowWithLadder;
32
16
  let ladderRunner: LadderRunner = runWorkflowWithLadder;
17
+
33
18
  export function __setAuthVerifierLadderForTest(fn: LadderRunner | null): void {
34
19
  ladderRunner = fn ?? runWorkflowWithLadder;
35
20
  }
36
21
 
37
- /** Two budgets bound the live `initiate` phase:
38
- *
39
- * - CHALLENGE budget (`maxInitiate`, env IMPRINT_AUTH_MAX_INITIATE, default 2):
40
- * how many initiates may actually REACH the user — a completed login (`ok`) or
41
- * an `AWAITING_2FA` (a challenge was delivered). This is what bounds how many
42
- * OTPs/pushes the user ever sees. It is a *lower bound* on pushes for a
43
- * multi-request initiate sequence: if the push is sent mid-sequence but a later
44
- * sub-request fails, the whole initiate is not counted — the attempt budget
45
- * backstops that.
46
- * - ATTEMPT budget (`maxInitiateAttempts`, env IMPRINT_AUTH_MAX_INITIATE_ATTEMPTS,
47
- * default 5): how many initiate tries total, INCLUDING ones that fail BEFORE any
48
- * challenge is delivered (an edge 403 / network error / bad request shape never
49
- * reaches the 2FA step, so it sends nothing). This is the runaway guard so a
50
- * login blocked pre-challenge can't loop forever without ever spending the
51
- * challenge budget. Clamped `>= maxInitiate` so it can never fire first.
52
- *
53
- * Why two: a pre-challenge failure (e.g. an Akamai edge 403 on the credential
54
- * POST) sends ZERO OTPs/pushes, so it must NOT consume the user-visible challenge
55
- * budget — otherwise a transient block exhausts the budget and a subsequently
56
- * corrected workflow can never be verified in the same run. */
57
- const DEFAULT_MAX_INITIATE = 2;
58
- const DEFAULT_MAX_INITIATE_ATTEMPTS = 5;
59
-
60
- type AuthPhase = 'initiate' | 'submit_otp' | 'complete';
61
-
62
- export interface AuthPhaseResult {
22
+ export interface AuthActionResult {
23
+ action: string;
63
24
  ok: boolean;
64
- /** Error code when !ok (e.g. AWAITING_2FA, AUTH_EXPIRED, BAD_RESPONSE,
65
- * BUDGET_EXHAUSTED [challenge cap], ATTEMPT_BUDGET_EXHAUSTED [attempt cap]). */
66
25
  error?: string;
67
26
  message?: string;
68
- /** Which ladder rung ran it (fetch / cdp-replay / playbook / …). */
27
+ nextAction?: string;
28
+ continuation?: Record<string, unknown>;
69
29
  usedBackend: string;
70
- twoFactorType?: string;
71
- /** The `${state.X}` values echoed on AWAITING_2FA, carried into the next phase. */
72
- twoFactorContext?: Record<string, unknown>;
73
- /** HTTP status code that produced this result, when one was received. Surfaced
74
- * so the compile agent sees the concrete code (e.g. 401 vs 400) directly. */
75
30
  status?: number;
76
- /** Truncated response body of the failing / initiate request (first ~500
77
- * chars) so the agent can inspect the server payload without re-running. */
78
31
  responseBodyPreview?: string;
79
- /** Wall-clock duration of this phase's live execution, in milliseconds. */
80
32
  durationMs: number;
81
33
  }
82
34
 
83
- function parsePositiveInt(value: string | undefined): number | undefined {
84
- if (value === undefined) return undefined;
85
- const n = Number.parseInt(value, 10);
86
- return Number.isFinite(n) && n > 0 ? n : undefined;
35
+ interface AuthPageInspection {
36
+ ok: boolean;
37
+ message?: string;
38
+ url?: string;
39
+ title?: string;
40
+ bodyText?: string;
41
+ bodyTextTruncated?: boolean;
42
+ cookies?: CdpPageSnapshot['cookies'];
87
43
  }
88
44
 
89
45
  export class AuthVerifier {
90
- /** Per-run CDP pool — the ONE live session reused across phases. */
91
46
  private readonly cdpPool = new Map<string, CdpBrowserFetch>();
92
- /** Initiates that actually reached the user (completed login or AWAITING_2FA).
93
- * Bounded by `maxInitiate` — the user-visible OTP/push budget. */
94
- private challengesIssued = 0;
95
- /** All initiate tries, including ones that failed before any challenge was
96
- * delivered. Bounded by `maxInitiateAttempts` — the runaway guard. */
97
- private initiateAttempts = 0;
98
- /** twoFactorContext echoed by the most recent initiate, threaded into the
99
- * completion phase so submit_otp can resolve `${state.X}`. */
100
- private lastTwoFactorContext: Record<string, unknown> | undefined;
101
- private readonly maxInitiate: number;
102
- /** Public so the orchestrator's progress line can show "attempt N/M". */
103
- readonly maxInitiateAttempts: number;
47
+ private continuation: Record<string, unknown> | undefined;
48
+ private readonly sensitiveValues = new Set<string>();
104
49
 
105
50
  constructor(
106
51
  private readonly workflowPath: string,
107
52
  private readonly credentials: CredentialStore,
108
- maxInitiate?: number,
109
- maxInitiateAttempts?: number,
110
53
  ) {
111
- this.maxInitiate =
112
- maxInitiate ??
113
- parsePositiveInt(process.env.IMPRINT_AUTH_MAX_INITIATE) ??
114
- DEFAULT_MAX_INITIATE;
115
- // The attempt cap can never fire before the challenge budget is usable.
116
- this.maxInitiateAttempts = Math.max(
117
- this.maxInitiate,
118
- maxInitiateAttempts ??
119
- parsePositiveInt(process.env.IMPRINT_AUTH_MAX_INITIATE_ATTEMPTS) ??
120
- DEFAULT_MAX_INITIATE_ATTEMPTS,
121
- );
122
- }
123
-
124
- /** User-visible challenges delivered so far (each ≈ one OTP/push the user saw). */
125
- get initiatesUsed(): number {
126
- return this.challengesIssued;
54
+ this.rememberSensitiveValues(credentials.values);
55
+ for (const cookie of credentials.cookies) this.rememberSensitiveValues(cookie.value);
56
+ for (const storage of credentials.storage ?? []) this.rememberSensitiveValues(storage.value);
127
57
  }
128
58
 
129
- /** All live initiate tries so far (incl. pre-challenge failures). */
130
- get attemptsUsed(): number {
131
- return this.initiateAttempts;
132
- }
133
-
134
- /** Run one auth phase live through the ladder, reusing the persistent session.
135
- * `initiate` is budget-capped; the completion phases reuse the prior context. */
136
- async runPhase(phase: AuthPhase, opts?: { otp_code?: string }): Promise<AuthPhaseResult> {
137
- if (phase === 'initiate') {
138
- // Gate 1: challenge budget — bounds OTPs/pushes the user actually sees.
139
- if (this.challengesIssued >= this.maxInitiate) {
140
- return {
141
- ok: false,
142
- error: 'BUDGET_EXHAUSTED',
143
- message: `Live-login budget of ${this.maxInitiate} delivered 2FA challenge(s) reached — do NOT request another initiate. Either give_up, or only call run_verification for the completion phase if a challenge is already pending.`,
144
- usedBackend: 'none',
145
- durationMs: 0,
146
- };
147
- }
148
- // Gate 2: attempt budget — runaway guard for logins blocked BEFORE any
149
- // challenge is delivered (edge 403 / network / bad request shape). These
150
- // send nothing, so they don't spend the challenge budget, but they must
151
- // not loop forever.
152
- if (this.initiateAttempts >= this.maxInitiateAttempts) {
153
- return {
154
- ok: false,
155
- error: 'ATTEMPT_BUDGET_EXHAUSTED',
156
- message: `Made ${this.maxInitiateAttempts} live initiate attempt(s), none of which delivered a 2FA challenge — the login is being blocked before it reaches the 2FA step (edge/anti-bot block or a defect in the request shape). Stop and give_up; a fresh run with corrected artifacts is needed.`,
157
- usedBackend: 'none',
158
- durationMs: 0,
159
- };
160
- }
161
- }
162
-
163
- const params: Record<string, string> = { action: phase };
164
- if (phase === 'submit_otp' && opts?.otp_code) params.otp_code = opts.otp_code;
165
-
166
- const t0 = Date.now();
59
+ async runAction(
60
+ action: string,
61
+ parameters: Record<string, string | number | boolean> = {},
62
+ options: { freshSession?: boolean; cleanSession?: boolean } = {},
63
+ ): Promise<AuthActionResult> {
64
+ if (options.freshSession || options.cleanSession) await this.reset();
65
+ this.rememberSensitiveValues(parameters);
66
+ const previousContinuation = this.continuation;
67
+ const startedAt = Date.now();
167
68
  const ladder = await ladderRunner({
168
69
  workflowPath: this.workflowPath,
169
- params,
170
- credentials: this.credentials,
70
+ params: { ...parameters, action },
71
+ credentials: options.cleanSession
72
+ ? { ...this.credentials, cookies: [], storage: [] }
73
+ : this.credentials,
171
74
  cdpPool: this.cdpPool,
172
- // Pin every phase to cdp-replay. This is the load-bearing decision for 2FA:
173
- // ONLY the cdp-replay rung keeps a live browser in `cdpPool`, and the
174
- // AWAITING_2FA carve-out in runCdpReplay retains it across the user-input
175
- // gap — so phase 2 reuses the EXACT session (cookies + server challenge +
176
- // in-page tokens) that phase 1 minted. Left to the probe, the fastest rung
177
- // returning AWAITING_2FA wins (often fetch/fetch-bootstrap), which is
178
- // stateless across calls → the completion poll/re-login 401s with
179
- // "tokens missing". cdp-replay is also the most anti-bot-robust rung, so
180
- // there's no downside for an (infrequent) auth flow.
181
75
  forceBackend: 'cdp-replay',
182
- // Carry the echoed challenge token into the completion phase so the same
183
- // session's submit_otp resolves ${state.X}. (Cookies ride the shared
184
- // pool/jar; this covers body-returned tokens.)
185
- initialState: phase === 'initiate' ? undefined : this.lastTwoFactorContext,
76
+ initialState: this.continuation,
186
77
  });
187
- const durationMs = Date.now() - t0;
188
-
189
- const r = ladder.result;
190
- if (phase === 'initiate') {
191
- // Every try counts toward the attempt cap…
192
- this.initiateAttempts += 1;
193
- // …but only a delivered challenge (completed login or AWAITING_2FA) counts
194
- // toward the user-visible challenge budget. A pre-challenge failure (403,
195
- // network, bad-response) sent no OTP/push, so it must not burn it. Strictly
196
- // `ok || AWAITING_2FA` — AUTH_EXPIRED / RATE_LIMITED are non-transport but
197
- // are NOT delivered challenges.
198
- if (r.ok || r.error === 'AWAITING_2FA') this.challengesIssued += 1;
78
+ const durationMs = Date.now() - startedAt;
79
+ const result = ladder.result;
80
+
81
+ for (const browser of this.cdpPool.values()) {
82
+ if (!browser.snapshotCookies) continue;
83
+ try {
84
+ for (const cookie of await browser.snapshotCookies()) {
85
+ this.rememberSensitiveValues(cookie.value);
86
+ }
87
+ } catch {
88
+ // A failed browser will be handled by the ladder's liveness policy.
89
+ }
199
90
  }
200
- if (!r.ok && r.error === 'AWAITING_2FA' && r.twoFactorContext) {
201
- this.lastTwoFactorContext = r.twoFactorContext;
91
+
92
+ if (result.ok) {
93
+ this.continuation = undefined;
94
+ } else if (result.continuation !== undefined) {
95
+ this.continuation = result.continuation;
96
+ } else {
97
+ this.continuation = previousContinuation;
202
98
  }
99
+ this.rememberSensitiveValues(this.continuation);
100
+
101
+ const actionResult: AuthActionResult = {
102
+ action,
103
+ ok: result.ok,
104
+ error: result.ok ? undefined : this.sanitize(result.error),
105
+ message: result.ok ? undefined : this.sanitize(result.message),
106
+ nextAction: result.ok ? undefined : this.sanitizeOptional(result.nextAction),
107
+ continuation: result.ok ? undefined : this.continuation,
108
+ usedBackend: ladder.usedBackend,
109
+ status: result.ok ? undefined : result.status,
110
+ responseBodyPreview: result.ok
111
+ ? undefined
112
+ : this.sanitizeOptional(result.responseBodyPreview),
113
+ durationMs,
114
+ };
203
115
  log(
204
- `phase=${phase} backend=${ladder.usedBackend} ok=${r.ok}${r.ok ? '' : ` error=${r.error}`} in ${durationMs}ms`,
116
+ `action=${JSON.stringify(action)} backend=${ladder.usedBackend} ok=${result.ok}${
117
+ result.ok ? '' : ` error=${result.error}`
118
+ } in ${durationMs}ms`,
205
119
  );
120
+ return actionResult;
121
+ }
206
122
 
207
- // Surface the full picture to the compile agent: concrete status code, the
208
- // response body preview (the initiate preview on AWAITING_2FA; the failing
209
- // body otherwise), backend, timing, and the carried challenge context.
210
- const responseBodyPreview = !r.ok
211
- ? (r.responseBodyPreview ?? r.loginResponsePreview)
212
- : r.loginResponsePreview;
123
+ async inspectPage(
124
+ options: { maxChars?: number; includeCookies?: boolean } = {},
125
+ ): Promise<AuthPageInspection> {
126
+ const browser = Array.from(this.cdpPool.values()).at(-1);
127
+ if (!browser) {
128
+ return { ok: false, message: 'No active verification browser session exists.' };
129
+ }
130
+ if (!browser.inspectPage) {
131
+ return { ok: false, message: 'The active verification browser cannot inspect pages.' };
132
+ }
133
+
134
+ const maxChars = Math.max(256, Math.min(20_000, Math.floor(options.maxChars ?? 8_000)));
135
+ const snapshot = await browser.inspectPage();
136
+ const bodyText = this.sanitize(snapshot.bodyText);
213
137
  return {
214
- ok: r.ok,
215
- error: r.ok ? undefined : r.error,
216
- message: r.ok ? undefined : r.message,
217
- usedBackend: ladder.usedBackend,
218
- twoFactorType: !r.ok ? r.twoFactorType : undefined,
219
- twoFactorContext: !r.ok ? r.twoFactorContext : undefined,
220
- status: !r.ok ? r.status : undefined,
221
- responseBodyPreview,
222
- durationMs,
138
+ ok: true,
139
+ url: this.sanitize(snapshot.url),
140
+ title: this.sanitize(snapshot.title),
141
+ bodyText: bodyText.slice(0, maxChars),
142
+ bodyTextTruncated: bodyText.length > maxChars,
143
+ ...(options.includeCookies === false
144
+ ? {}
145
+ : {
146
+ cookies: snapshot.cookies.map((cookie) => ({
147
+ name: cookie.name,
148
+ domain: cookie.domain,
149
+ path: cookie.path,
150
+ expires: cookie.expires,
151
+ httpOnly: cookie.httpOnly,
152
+ secure: cookie.secure,
153
+ sameSite: cookie.sameSite,
154
+ })),
155
+ }),
223
156
  };
224
157
  }
225
158
 
226
- /** Close every pooled browser. MUST be called (teach's `finally`) — the pool
227
- * holds a live Chrome across the user-input wait, so it never auto-closes. */
228
159
  async drain(): Promise<void> {
229
- for (const cf of this.cdpPool.values()) {
230
- await cf.close().catch(() => {});
160
+ await this.reset();
161
+ }
162
+
163
+ private async reset(): Promise<void> {
164
+ for (const browser of this.cdpPool.values()) {
165
+ await browser.close().catch(() => {});
231
166
  }
232
167
  this.cdpPool.clear();
168
+ this.continuation = undefined;
169
+ }
170
+
171
+ private rememberSensitiveValues(value: unknown, seen = new Set<object>()): void {
172
+ if (typeof value === 'string') {
173
+ this.rememberSensitiveValue(value);
174
+ return;
175
+ }
176
+ if (value === null || typeof value !== 'object' || seen.has(value)) return;
177
+
178
+ seen.add(value);
179
+ for (const nested of Object.values(value)) this.rememberSensitiveValues(nested, seen);
180
+ }
181
+
182
+ private rememberSensitiveValue(value: string): void {
183
+ if (value.length < 3) return;
184
+ this.sensitiveValues.add(value);
185
+
186
+ const encoded = encodeURIComponent(value);
187
+ const lowercaseEncoded = encoded.replace(/%[0-9A-F]{2}/g, (encodedByte) =>
188
+ encodedByte.toLowerCase(),
189
+ );
190
+ this.sensitiveValues.add(encoded);
191
+ this.sensitiveValues.add(encoded.replace(/%20/g, '+'));
192
+ this.sensitiveValues.add(lowercaseEncoded);
193
+ this.sensitiveValues.add(lowercaseEncoded.replace(/%20/g, '+'));
194
+ this.sensitiveValues.add(JSON.stringify(value).slice(1, -1));
195
+ this.sensitiveValues.add(
196
+ Array.from(value, (character) =>
197
+ /[a-z0-9]/i.test(character) ? character : `&#${character.codePointAt(0) ?? 0};`,
198
+ ).join(''),
199
+ );
200
+ this.sensitiveValues.add(
201
+ Array.from(value, (character) =>
202
+ /[a-z0-9]/i.test(character)
203
+ ? character
204
+ : `&#x${(character.codePointAt(0) ?? 0).toString(16)};`,
205
+ ).join(''),
206
+ );
207
+ }
208
+
209
+ private sanitize(value: string): string {
210
+ let sanitized = redactFreeformText(value).redacted;
211
+ const secrets = Array.from(this.sensitiveValues).sort((a, b) => b.length - a.length);
212
+ for (const secret of secrets) {
213
+ sanitized = sanitized.split(secret).join('[REDACTED]');
214
+ }
215
+ return sanitized;
216
+ }
217
+
218
+ private sanitizeOptional(value: string | undefined): string | undefined {
219
+ return value === undefined ? undefined : this.sanitize(value);
233
220
  }
234
221
  }
@@ -15,7 +15,14 @@ export type BackendsCacheStatus =
15
15
  cache: BackendsCache;
16
16
  }
17
17
  | {
18
- status: 'stale' | 'invalid';
18
+ status: 'stale';
19
+ path: string;
20
+ cache: BackendsCache;
21
+ reason: string;
22
+ remediation: string;
23
+ }
24
+ | {
25
+ status: 'invalid';
19
26
  path: string;
20
27
  reason: string;
21
28
  remediation: string;
@@ -66,6 +73,15 @@ export function loadBackendsCacheStatus(
66
73
  try {
67
74
  const raw = JSON.parse(readFileSync(path, 'utf8'));
68
75
  const parsed = BackendsCacheSchema.parse(raw);
76
+ const invalidPreferredReason = invalidPreferredOrderReason(parsed);
77
+ if (invalidPreferredReason) {
78
+ if (opts.warn !== false) {
79
+ process.stderr.write(
80
+ `[imprint] backends.json at ${path} has unsafe preferred backends — ignoring (run \`${remediation}\` to regenerate): ${invalidPreferredReason}\n`,
81
+ );
82
+ }
83
+ return { status: 'invalid', path, reason: invalidPreferredReason, remediation };
84
+ }
69
85
  if (parsed.schemaVersion && parsed.schemaVersion >= 2 && parsed.workflowHash) {
70
86
  const workflowPath = pathResolve(toolDir, 'workflow.json');
71
87
  if (existsSync(workflowPath)) {
@@ -77,19 +93,10 @@ export function loadBackendsCacheStatus(
77
93
  `[imprint] backends.json at ${path} is stale for current workflow — ignoring (run \`${remediation}\` to regenerate)\n`,
78
94
  );
79
95
  }
80
- return { status: 'stale', path, reason, remediation };
96
+ return { status: 'stale', path, cache: parsed, reason, remediation };
81
97
  }
82
98
  }
83
99
  }
84
- const invalidPreferredReason = invalidPreferredOrderReason(parsed);
85
- if (invalidPreferredReason) {
86
- if (opts.warn !== false) {
87
- process.stderr.write(
88
- `[imprint] backends.json at ${path} has unsafe preferred backends — ignoring (run \`${remediation}\` to regenerate): ${invalidPreferredReason}\n`,
89
- );
90
- }
91
- return { status: 'invalid', path, reason: invalidPreferredReason, remediation };
92
- }
93
100
  return { status: 'ok', path, cache: parsed };
94
101
  } catch (err) {
95
102
  const reason = err instanceof Error ? err.message : String(err);