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,13 +1,10 @@
1
1
  /**
2
- * Auth-specific compile tools + verification, shared by both auth compile
3
- * drivers:
4
- * - the in-process runAgentLoop path (auth-compile-agent.ts, anthropic-api)
5
- * - the claude-cli / codex-cli path (mcp-compile-server.ts in auth mode)
6
- *
7
- * Keeping these in one module guarantees the agent sees an identical toolset
8
- * and identical external verification regardless of which provider drives it.
2
+ * Recording tools and contract-level verification shared by every auth compile
3
+ * provider. Site behavior is decided by the compile agent and encoded in the
4
+ * auth action program, not inferred here.
9
5
  */
10
6
 
7
+ import { createHash } from 'node:crypto';
11
8
  import { existsSync, readFileSync } from 'node:fs';
12
9
  import { join as pathJoin } from 'node:path';
13
10
  import type { AgentTool } from './agent.ts';
@@ -18,50 +15,38 @@ import {
18
15
  buildReadResponseBodyTool,
19
16
  buildReadSessionSummaryTool,
20
17
  buildRunBashTool,
18
+ buildSearchRequestsTool,
21
19
  buildWriteFileTool,
22
20
  } from './compile-tools.ts';
23
- import { collectStatePlaceholders } from './runtime.ts';
24
- import { WorkflowSchema } from './types.ts';
25
- import type { Session } from './types.ts';
21
+ 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
24
 
27
25
  type TeachCredentials = { site: string; values: Record<string, string> };
28
26
 
29
- /** Carried by the CLI compile drivers (claude-cli / codex-cli) to switch the
30
- * shared spawn machinery from a data compile to an auth compile: the MCP
31
- * server is launched in auth mode, and the agent gets the auth tool list +
32
- * initial prompt. */
27
+ export const AUTH_VERIFICATION_ATTEMPT_SENTINEL = '.auth-verification-attempt.json';
28
+
33
29
  export interface AuthCliCompileMode {
34
- /** Site slug — the MCP server loads credentials from the store for it. */
35
30
  site: string;
36
- /** JSON-serialized AuthToolPlan, passed to the MCP server. */
37
31
  authPlanJson: string;
38
- /** Short tool names to pre-approve (the driver prefixes them per provider). */
39
32
  allowedTools: readonly string[];
40
- /** The initial user message handed to the agent on turn 1. */
41
33
  initialPrompt: string;
42
34
  }
43
35
 
44
- /** Short names of every tool the auth compile agent may call (excluding the
45
- * lifecycle done/give_up). Used by the claude-cli path to build --allowedTools.
46
- * The agent SHAPES from the recording with the read/write tools and never logs
47
- * in itself; live login happens only via the checkpoint tools (run_verification
48
- * / prompt_user / wait_for_cooldown), which the orchestrator executes. */
49
36
  export const AUTH_COMPILE_TOOL_NAMES = [
50
37
  'read_session_summary',
38
+ 'search_requests',
51
39
  'read_request',
52
40
  'read_response_body',
53
41
  'write_file',
54
42
  'read_file',
55
43
  'run_bash',
56
44
  'run_verification',
45
+ 'inspect_verification_page',
57
46
  'prompt_user',
58
47
  'wait_for_cooldown',
59
48
  ] as const;
60
49
 
61
- /** Assemble the auth compile SHAPING toolset (read/write only — no live login).
62
- * The checkpoint tools (run_verification/prompt_user/wait_for_cooldown) and
63
- * done()/give_up() are appended by the driver (mcp-compile-server in auth mode)
64
- * because they are orchestrator-mediated, not executed in this process. */
65
50
  export function buildAuthCompileTools(
66
51
  session: Session,
67
52
  toolDir: string,
@@ -71,139 +56,336 @@ export function buildAuthCompileTools(
71
56
  const context: CompileToolContext = { teachCredentials };
72
57
  return [
73
58
  buildReadSessionSummaryTool(session, context),
59
+ buildSearchRequestsTool(session),
74
60
  buildReadRequestTool(session),
75
61
  buildReadResponseBodyTool(session),
76
- // Auth runs on cdp-replay only (a real headed browser that replays the
77
- // recorded requests in-page) — there is no playbook rung in the auth path, so
78
- // the agent emits workflow.json only. No playbook.yaml in the write allowlist.
79
62
  buildWriteFileTool(toolDir),
80
63
  buildReadFileTool(toolDir),
81
64
  buildRunBashTool(toolDir),
82
65
  ];
83
66
  }
84
67
 
85
- // ─── External verification ──────────────────────────────────────────────────
86
-
87
- /** Lightweight structural checks after the agent calls done(). The agent has
88
- * already proven the workflow works live (AWAITING_2FA / ok:true from
89
- * run_verification); this just guards the artifact's shape. Returns a list
90
- * of failure strings (empty = passed).
91
- *
92
- * `requiredSessionCaptures` carries the build plan's authTool captures: every
93
- * durable token a downstream DATA tool consumes via `${credential.<name>}`
94
- * (`usedAs` names the header it injects). For each one, the auth workflow MUST
95
- * declare a matching `authConfig.sessionCapture` so the login persists it —
96
- * otherwise the data tool's contracted auth header can never resolve at runtime. */
68
+ export function authWorkflowPreflightFailures(
69
+ toolDir: string,
70
+ session?: Session,
71
+ requiredCredentialNames: readonly string[] = [],
72
+ ): string[] {
73
+ const workflowPath = pathJoin(toolDir, 'workflow.json');
74
+ if (!existsSync(workflowPath)) return ['workflow.json does not exist'];
75
+ return parseWorkflow(workflowPath, requiredCredentialNames, session).failures;
76
+ }
77
+
97
78
  export function authExternalVerification(
98
79
  toolDir: string,
99
80
  requiredSessionCaptures: Array<{ name: string; usedAs?: string }> = [],
81
+ options: { requireLiveAttempt?: boolean; requiredCredentialNames?: readonly string[] } = {},
100
82
  ): string[] {
101
- const failures: string[] = [];
102
83
  const workflowPath = pathJoin(toolDir, 'workflow.json');
84
+ if (!existsSync(workflowPath)) return ['workflow.json does not exist'];
103
85
 
104
- if (!existsSync(workflowPath)) {
105
- failures.push('workflow.json does not exist');
106
- return failures;
86
+ const parsed = parseWorkflow(workflowPath, options.requiredCredentialNames);
87
+ if (!parsed.workflow) return parsed.failures;
88
+ const failures = [...parsed.failures];
89
+ const workflow = parsed.workflow;
90
+ const persisted = new Set(workflow.authConfig?.persist ?? []);
91
+
92
+ const missingContracts = requiredSessionCaptures.filter((capture) => {
93
+ const target = (capture.usedAs ?? '').toLowerCase();
94
+ return (
95
+ target.startsWith('header:') &&
96
+ target !== 'header:cookie' &&
97
+ target !== 'header:set-cookie' &&
98
+ !persisted.has(capture.name)
99
+ );
100
+ });
101
+ if (missingContracts.length > 0) {
102
+ failures.push(
103
+ `authConfig.persist must include downstream credential capture(s): ${missingContracts
104
+ .map((capture) => capture.name)
105
+ .join(', ')}`,
106
+ );
107
107
  }
108
108
 
109
+ if (options.requireLiveAttempt) {
110
+ failures.push(...liveAttemptFailures(toolDir, workflow));
111
+ }
112
+ return failures;
113
+ }
114
+
115
+ function parseWorkflow(
116
+ path: string,
117
+ requiredCredentialNames: readonly string[] = [],
118
+ session?: Session,
119
+ ): { workflow?: Workflow; failures: string[] } {
109
120
  let raw: unknown;
110
121
  try {
111
- raw = JSON.parse(readFileSync(workflowPath, 'utf8'));
122
+ raw = JSON.parse(readFileSync(path, 'utf8'));
112
123
  } catch (err) {
113
- failures.push(
114
- `workflow.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
115
- );
116
- return failures;
124
+ return {
125
+ failures: [
126
+ `workflow.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
127
+ ],
128
+ };
117
129
  }
118
130
 
119
131
  const parsed = WorkflowSchema.safeParse(raw);
120
132
  if (!parsed.success) {
121
- failures.push(`workflow.json does not match WorkflowSchema: ${parsed.error.message}`);
133
+ return {
134
+ failures: [`workflow.json does not match WorkflowSchema: ${parsed.error.message}`],
135
+ };
136
+ }
137
+ return {
138
+ workflow: parsed.data,
139
+ failures: authProgramFailures(parsed.data, requiredCredentialNames, session),
140
+ };
141
+ }
142
+
143
+ function authProgramFailures(
144
+ workflow: Workflow,
145
+ requiredCredentialNames: readonly string[] = [],
146
+ session?: Session,
147
+ ): string[] {
148
+ const failures: string[] = [];
149
+ if (workflow.toolKind !== 'authenticate') {
150
+ failures.push(`workflow.toolKind must be "authenticate"`);
122
151
  return failures;
123
152
  }
153
+ const config = workflow.authConfig;
154
+ if (!config) return ['workflow.authConfig is missing'];
124
155
 
125
- const workflow = parsed.data;
156
+ const reachableRequestIndexes = new Set(
157
+ Object.values(config.actions).flatMap((action) => action.steps.map((step) => step.request)),
158
+ );
159
+ const executable = JSON.stringify({
160
+ bootstrap: workflow.bootstrap,
161
+ requests: workflow.requests.filter((_, index) => reachableRequestIndexes.has(index)),
162
+ });
163
+ for (const name of requiredCredentialNames) {
164
+ if (!executable.includes(`\${credential.${name}}`)) {
165
+ failures.push(
166
+ `workflow must use planned credential ${JSON.stringify(name)} as \${credential.${name}} in an executable request or bootstrap`,
167
+ );
168
+ }
169
+ }
170
+ if (!config.actions[config.entry]) {
171
+ failures.push(`authConfig.entry references unknown action ${JSON.stringify(config.entry)}`);
172
+ }
126
173
 
127
- if (workflow.toolKind !== 'authenticate') {
128
- failures.push(
129
- `workflow.toolKind must be 'authenticate', got '${workflow.toolKind ?? '(undefined)'}'`,
130
- );
174
+ const actionParameter = workflow.parameters.find((parameter) => parameter.name === 'action');
175
+ const actionNames = Object.keys(config.actions);
176
+ if (!actionParameter) {
177
+ failures.push('workflow.parameters must declare the auth action selector');
178
+ } else {
179
+ const choices = new Set(actionParameter.choices ?? []);
180
+ const mismatchedChoices =
181
+ choices.size !== actionNames.length || actionNames.some((name) => !choices.has(name));
182
+ if (
183
+ actionParameter.type !== 'string' ||
184
+ actionParameter.default !== config.entry ||
185
+ mismatchedChoices
186
+ ) {
187
+ failures.push(
188
+ 'workflow action parameter must be a string whose default is authConfig.entry and whose choices exactly match authConfig.actions',
189
+ );
190
+ }
131
191
  }
132
192
 
133
- if (!workflow.requests || workflow.requests.length === 0) {
134
- failures.push('workflow.requests is empty — auth tool needs at least one request');
193
+ const parameters = new Set(workflow.parameters.map((parameter) => parameter.name));
194
+ const captures = new Set<string>();
195
+ const captureOwners = new Map<
196
+ string,
197
+ {
198
+ request: Workflow['requests'][number];
199
+ capture: NonNullable<Workflow['requests'][number]['captures']>[number];
200
+ }
201
+ >();
202
+ for (const request of workflow.requests) {
203
+ for (const capture of request.captures ?? []) {
204
+ if (captures.has(capture.name)) {
205
+ failures.push(
206
+ `capture name ${JSON.stringify(capture.name)} must be unique across the auth workflow`,
207
+ );
208
+ }
209
+ captures.add(capture.name);
210
+ captureOwners.set(capture.name, { request, capture });
211
+ }
212
+ }
213
+ for (const action of Object.values(config.actions)) {
214
+ for (const step of action.steps) {
215
+ if (step.repeat) {
216
+ if (captures.has(step.repeat.until.name)) {
217
+ failures.push(
218
+ `capture name ${JSON.stringify(step.repeat.until.name)} must be unique across the auth workflow`,
219
+ );
220
+ }
221
+ captures.add(step.repeat.until.name);
222
+ const request = workflow.requests[step.request];
223
+ if (request) {
224
+ captureOwners.set(step.repeat.until.name, { request, capture: step.repeat.until });
225
+ }
226
+ }
227
+ }
135
228
  }
136
229
 
137
- // Structural 2FA checks — assert the *shape* each structural case needs, never
138
- // a channel name or response string. WorkflowSchema already constrains the
139
- // enum to otp|push|none, so we only check that the artifact carries what the
140
- // runtime needs to actually run that case.
141
- const authConfig = workflow.authConfig;
142
- if (!authConfig) {
143
- failures.push('workflow.authConfig is missing');
144
- } else if (authConfig.twoFactorType === 'push') {
145
- // Push completes by polling an endpoint until a recording-grounded terminal
146
- // (pollTerminal) resolves; without an endpoint there's nothing to poll.
147
- if (!authConfig.pollEndpoint) {
148
- failures.push("authConfig.twoFactorType is 'push' but authConfig.pollEndpoint is missing");
230
+ let hasSuccess = false;
231
+ for (const [name, action] of Object.entries(config.actions)) {
232
+ for (const parameter of action.parameters) {
233
+ if (!parameters.has(parameter)) {
234
+ failures.push(
235
+ `action ${JSON.stringify(name)} references unknown parameter ${JSON.stringify(parameter)}`,
236
+ );
237
+ }
149
238
  }
150
- } else if (authConfig.twoFactorType === 'otp') {
151
- // OTP completes via a second request carrying the live code.
152
- if (!workflow.parameters.some((p) => p.name === 'otp_code')) {
153
- failures.push("authConfig.twoFactorType is 'otp' but no 'otp_code' parameter is declared");
239
+ for (const [stepIndex, step] of action.steps.entries()) {
240
+ if (!workflow.requests[step.request]) {
241
+ failures.push(
242
+ `action ${JSON.stringify(name)} step ${stepIndex} references missing request ${step.request}`,
243
+ );
244
+ }
245
+ if (step.onError === 'retry' && !step.repeat) {
246
+ failures.push(
247
+ `action ${JSON.stringify(name)} step ${stepIndex} uses onError="retry" without repeat bounds`,
248
+ );
249
+ }
154
250
  }
155
- // Every ${state.X} the completion requests read must be carried across the
156
- // stateless initiate→submit_otp gap: either echoed via twoFactorContext or
157
- // (re)produced by a capture on an initiate-phase request.
158
- const initiateCount = authConfig.initiateRequestCount || 0;
159
- const completionRequests = workflow.requests.slice(initiateCount);
160
- const initiateCaptureNames = new Set<string>();
161
- // slice(0, initiateCount) — NOT `initiateCount || undefined`: when the count is
162
- // 0/unset there are NO initiate-phase requests, so `slice(0, 0)` must yield an
163
- // empty set. `|| undefined` would make this `slice(0, undefined)` = ALL requests,
164
- // counting completion-phase captures as "covered" and defeating the check.
165
- for (const req of workflow.requests.slice(0, initiateCount)) {
166
- for (const cap of req.captures ?? []) initiateCaptureNames.add(cap.name);
251
+ for (const evidence of action.outcome.evidence) {
252
+ if (!captures.has(evidence)) {
253
+ failures.push(
254
+ `action ${JSON.stringify(name)} references unknown evidence capture ${JSON.stringify(evidence)}`,
255
+ );
256
+ }
167
257
  }
168
- const covered = new Set([...(authConfig.twoFactorContext ?? []), ...initiateCaptureNames]);
169
- const uncovered = new Set<string>();
170
- for (const req of completionRequests) {
171
- for (const name of collectStatePlaceholders(req)) {
172
- if (!covered.has(name)) uncovered.add(name);
258
+ if (action.outcome.type === 'pause') {
259
+ if (!config.actions[action.outcome.next]) {
260
+ failures.push(
261
+ `action ${JSON.stringify(name)} pauses to unknown action ${JSON.stringify(action.outcome.next)}`,
262
+ );
173
263
  }
264
+ for (const carried of action.outcome.carry) {
265
+ if (!captures.has(carried)) {
266
+ failures.push(
267
+ `action ${JSON.stringify(name)} carries unknown capture ${JSON.stringify(carried)}`,
268
+ );
269
+ }
270
+ }
271
+ } else {
272
+ hasSuccess = true;
174
273
  }
175
- if (uncovered.size > 0) {
176
- const refs = [...uncovered].map((n) => `\${state.${n}}`).join(', ');
274
+ }
275
+
276
+ if (!hasSuccess) failures.push('authConfig.actions has no success outcome');
277
+ for (const name of config.persist) {
278
+ if (!captures.has(name)) {
279
+ failures.push(`authConfig.persist references unknown capture ${JSON.stringify(name)}`);
280
+ continue;
281
+ }
282
+ const owner = captureOwners.get(name);
283
+ if (!owner) continue;
284
+ const { request, capture } = owner;
285
+ if (request.recordingRequestSeq === undefined) {
177
286
  failures.push(
178
- `submit_otp requests reference ${refs} but those are neither listed in authConfig.twoFactorContext nor captured on an initiate-phase request — they will be undefined on the stateless submit_otp call`,
287
+ `persisted capture ${JSON.stringify(name)} must declare recordingRequestSeq on its producing request`,
288
+ );
289
+ } else if (session) {
290
+ const recorded = session.requests.find(
291
+ (candidate) => candidate.seq === request.recordingRequestSeq,
179
292
  );
293
+ if (!recorded) {
294
+ failures.push(
295
+ `persisted capture ${JSON.stringify(name)} references missing recorded request seq ${request.recordingRequestSeq}`,
296
+ );
297
+ } else if (!recordedRequestMatchesWorkflow(recorded, request)) {
298
+ failures.push(
299
+ `persisted capture ${JSON.stringify(name)} recordingRequestSeq ${request.recordingRequestSeq} does not match its workflow request`,
300
+ );
301
+ } else {
302
+ if (!recordedResponseProducesCapture(recorded, capture)) {
303
+ failures.push(
304
+ `persisted capture ${JSON.stringify(name)} is not produced by recorded request seq ${request.recordingRequestSeq}`,
305
+ );
306
+ }
307
+ }
180
308
  }
181
309
  }
310
+ if (!reachableSuccess(config.entry, config.actions)) {
311
+ failures.push('authConfig.entry cannot reach a success outcome');
312
+ }
313
+ return failures;
314
+ }
182
315
 
183
- // Downstream auth contract: every durable token a DATA tool consumes via
184
- // ${credential.<name>} (a build-plan authTool capture whose usedAs is a header)
185
- // must be persisted by a matching authConfig.sessionCapture, or the data tool's
186
- // contracted auth header can never resolve at runtime. Cookies persist
187
- // automatically, so only the NON-cookie header contracts are checked here.
188
- const headerContracts = requiredSessionCaptures.filter((c) => {
189
- const u = (c.usedAs ?? '').toLowerCase();
190
- // Cookies persist automatically via the jar — only NON-cookie header tokens
191
- // need a sessionCapture.
192
- return u.startsWith('header:') && u !== 'header:cookie' && u !== 'header:set-cookie';
193
- });
194
- if (headerContracts.length > 0) {
195
- const persisted = new Set((authConfig?.sessionCapture ?? []).map((c) => c.name));
196
- const missing = headerContracts.filter((c) => !persisted.has(c.name));
197
- if (missing.length > 0) {
198
- failures.push(
199
- `the build plan's data tools consume ${missing
200
- .map((c) => `\`\${credential.${c.name}}\` (used as ${c.usedAs})`)
201
- .join(', ')} but workflow.authConfig.sessionCapture does not persist ${
202
- missing.length === 1 ? 'it' : 'them'
203
- }. Add a sessionCapture for each so a SUCCESSFUL login stores the token as a durable credential the data tools can reuse — grounded in the login completion response (a body field or a response header), never invented.`,
204
- );
316
+ function recordedResponseProducesCapture(
317
+ request: Session['requests'][number],
318
+ capture: NonNullable<Workflow['requests'][number]['captures']>[number],
319
+ ): boolean {
320
+ const response = request.response;
321
+ if (!response || capture.source === 'cookie') return false;
322
+ const body = response.body ?? '';
323
+ let value: unknown;
324
+ if (capture.source === 'json') {
325
+ try {
326
+ value = jsonpath(JSON.parse(body), capture.path);
327
+ } catch {
328
+ return false;
329
+ }
330
+ } else if (capture.source === 'response_header') {
331
+ value =
332
+ capture.header.toLowerCase() === 'x-imprint-final-url'
333
+ ? request.url
334
+ : captureHeader(new Headers(response.headers), capture.header, capture.mode);
335
+ } else {
336
+ try {
337
+ value = new RegExp(capture.pattern).exec(body)?.[capture.group ?? 1];
338
+ } catch {
339
+ return false;
205
340
  }
206
341
  }
342
+ return captureValueMatches(value, capture.equals);
343
+ }
207
344
 
208
- return failures;
345
+ function reachableSuccess(
346
+ entry: string,
347
+ actions: NonNullable<Workflow['authConfig']>['actions'],
348
+ ): boolean {
349
+ const seen = new Set<string>();
350
+ let current: string | undefined = entry;
351
+ while (current && !seen.has(current)) {
352
+ seen.add(current);
353
+ const action: (typeof actions)[string] | undefined = actions[current];
354
+ if (!action) return false;
355
+ if (action.outcome.type === 'success') return true;
356
+ current = action.outcome.next;
357
+ }
358
+ return false;
359
+ }
360
+
361
+ export function authWorkflowHash(workflow: Workflow): string {
362
+ return createHash('sha256').update(JSON.stringify(workflow)).digest('hex');
363
+ }
364
+
365
+ function liveAttemptFailures(toolDir: string, workflow: Workflow): string[] {
366
+ const path = pathJoin(toolDir, AUTH_VERIFICATION_ATTEMPT_SENTINEL);
367
+ if (!existsSync(path)) {
368
+ return ['no live auth verification attempt was recorded; call run_verification before done'];
369
+ }
370
+
371
+ let attempt: { action?: unknown; ok?: unknown; workflowHash?: unknown };
372
+ try {
373
+ attempt = JSON.parse(readFileSync(path, 'utf8')) as typeof attempt;
374
+ } catch (err) {
375
+ return [
376
+ `live auth verification record is invalid: ${err instanceof Error ? err.message : String(err)}`,
377
+ ];
378
+ }
379
+ if (attempt.ok !== true || typeof attempt.action !== 'string') {
380
+ return ['the most recent live auth verification did not succeed'];
381
+ }
382
+ if (attempt.workflowHash !== authWorkflowHash(workflow)) {
383
+ return ['workflow.json changed after the most recent successful live auth verification'];
384
+ }
385
+ const action = workflow.authConfig?.actions[attempt.action];
386
+ return action?.outcome.type === 'success'
387
+ ? []
388
+ : [
389
+ `the most recent successful live action ${JSON.stringify(attempt.action)} does not declare a success outcome`,
390
+ ];
209
391
  }