imprint-mcp 0.6.1 → 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.
@@ -0,0 +1,187 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ appendFileSync,
4
+ closeSync,
5
+ existsSync,
6
+ openSync,
7
+ readFileSync,
8
+ statSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { dirname, join as pathJoin } from 'node:path';
13
+ import { loadBackendsCacheStatus } from './backend-cache.ts';
14
+ import { runWorkflowWithLadder } from './backend-ladder.ts';
15
+ import type { CredentialStore } from './runtime.ts';
16
+ import { WorkflowSchema } from './types.ts';
17
+
18
+ export const LIVE_EVIDENCE_PATH_ENV = 'IMPRINT_LIVE_EVIDENCE_PATH';
19
+ export const LIVE_PREFERRED_BACKEND_ONLY_ENV = 'IMPRINT_LIVE_PREFERRED_BACKEND_ONLY';
20
+
21
+ type WorkflowParams = Record<string, string | number | boolean>;
22
+ type LadderRun = Awaited<ReturnType<typeof runWorkflowWithLadder>>;
23
+
24
+ export interface LiveIntegrationEvidence {
25
+ schemaVersion: 1;
26
+ kind: 'call';
27
+ label: string;
28
+ caseName: string;
29
+ toolName: string;
30
+ requestedParams: WorkflowParams;
31
+ effectiveParams: WorkflowParams;
32
+ result: LadderRun['result'];
33
+ usedBackend: LadderRun['usedBackend'];
34
+ attempts: LadderRun['attempts'];
35
+ durationMs: number;
36
+ }
37
+
38
+ function scalarDefaults(parameters: Array<{ name: string; default?: unknown }>): WorkflowParams {
39
+ const defaults: WorkflowParams = {};
40
+ for (const parameter of parameters) {
41
+ const value = parameter.default;
42
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
43
+ defaults[parameter.name] = value;
44
+ }
45
+ }
46
+ return defaults;
47
+ }
48
+
49
+ function processIsAlive(pid: number): boolean {
50
+ try {
51
+ process.kill(pid, 0);
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ async function acquireSiteLiveLock(workflowPath: string): Promise<() => void> {
59
+ const siteDir = dirname(dirname(workflowPath));
60
+ const lockPath = pathJoin(siteDir, '.imprint-live-verification.lock');
61
+ const deadline = Date.now() + 10 * 60_000;
62
+
63
+ while (Date.now() < deadline) {
64
+ try {
65
+ const fd = openSync(lockPath, 'wx');
66
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
67
+ closeSync(fd);
68
+ return () => {
69
+ try {
70
+ unlinkSync(lockPath);
71
+ } catch {
72
+ // Best effort: a later acquisition can clean up a stale lock.
73
+ }
74
+ };
75
+ } catch (error) {
76
+ const code = (error as NodeJS.ErrnoException).code;
77
+ if (code !== 'EEXIST') throw error;
78
+ try {
79
+ const current = JSON.parse(readFileSync(lockPath, 'utf8')) as {
80
+ pid?: number;
81
+ createdAt?: number;
82
+ };
83
+ const stale =
84
+ typeof current.pid === 'number'
85
+ ? !processIsAlive(current.pid)
86
+ : Date.now() - (current.createdAt ?? 0) > 10 * 60_000;
87
+ if (stale) {
88
+ unlinkSync(lockPath);
89
+ continue;
90
+ }
91
+ } catch {
92
+ try {
93
+ // Another process can observe the lock between exclusive creation
94
+ // and the tiny metadata write. Never delete a fresh, partially
95
+ // written lock; only reap malformed metadata after a long stale age.
96
+ if (Date.now() - statSync(lockPath).mtimeMs > 10 * 60_000) {
97
+ unlinkSync(lockPath);
98
+ continue;
99
+ }
100
+ } catch {
101
+ // Another process may have released/replaced the lock. Retry below.
102
+ }
103
+ }
104
+ await new Promise((resolve) => setTimeout(resolve, 250));
105
+ }
106
+ }
107
+ throw new Error(`Timed out waiting for same-site live verification lock: ${lockPath}`);
108
+ }
109
+
110
+ /**
111
+ * Compile-only wrapper around the backend ladder. Production generated tools do
112
+ * not use this function. During `imprint teach`, it captures the actual tool
113
+ * input and parsed result for an independent semantic verifier.
114
+ */
115
+ export async function runCapturedIntegrationCase(opts: {
116
+ caseName: string;
117
+ workflowPath: string;
118
+ params: WorkflowParams;
119
+ credentials?: CredentialStore;
120
+ /** Final semantic verification pins the cache winner so a failed preferred
121
+ * backend becomes explicit reprobe feedback instead of a hidden ladder walk. */
122
+ preferredOnlyBackend?: boolean;
123
+ }): Promise<LadderRun> {
124
+ const workflow = WorkflowSchema.parse(JSON.parse(readFileSync(opts.workflowPath, 'utf8')));
125
+ const release = await acquireSiteLiveLock(opts.workflowPath);
126
+ const startedAt = Date.now();
127
+ let run: LadderRun;
128
+ try {
129
+ let forceBackend: LadderRun['usedBackend'] | undefined;
130
+ if (opts.preferredOnlyBackend || process.env[LIVE_PREFERRED_BACKEND_ONLY_ENV] === '1') {
131
+ const toolDir = dirname(opts.workflowPath);
132
+ const status = loadBackendsCacheStatus(
133
+ workflow.site ?? '',
134
+ dirname(dirname(toolDir)),
135
+ toolDir,
136
+ { warn: false, toolName: workflow.toolName },
137
+ );
138
+ forceBackend = status.status === 'ok' ? status.cache.preferredOrder[0] : undefined;
139
+ if (!forceBackend) {
140
+ throw new Error(
141
+ `Live verifier backend is not prepared for ${workflow.toolName}; call prepare_live_backend before running the suite.`,
142
+ );
143
+ }
144
+ }
145
+ run = await runWorkflowWithLadder({
146
+ workflowPath: opts.workflowPath,
147
+ params: opts.params,
148
+ credentials: opts.credentials,
149
+ forceBackend,
150
+ });
151
+ } finally {
152
+ release();
153
+ }
154
+
155
+ const evidencePath = process.env[LIVE_EVIDENCE_PATH_ENV];
156
+ if (evidencePath) {
157
+ const evidence: LiveIntegrationEvidence = {
158
+ schemaVersion: 1,
159
+ kind: 'call',
160
+ label: `${opts.caseName}:${randomUUID().slice(0, 8)}`,
161
+ caseName: opts.caseName,
162
+ toolName: workflow.toolName,
163
+ requestedParams: opts.params,
164
+ effectiveParams: { ...scalarDefaults(workflow.parameters), ...opts.params },
165
+ result: run.result,
166
+ usedBackend: run.usedBackend,
167
+ attempts: run.attempts,
168
+ durationMs: Date.now() - startedAt,
169
+ };
170
+ appendFileSync(evidencePath, `${JSON.stringify(evidence)}\n`, { mode: 0o600 });
171
+ }
172
+ return run;
173
+ }
174
+
175
+ export function readLiveIntegrationEvidence(path: string): LiveIntegrationEvidence[] {
176
+ if (!existsSync(path)) return [];
177
+ const evidence: LiveIntegrationEvidence[] = [];
178
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
179
+ if (!line.trim()) continue;
180
+ const parsed = JSON.parse(line) as LiveIntegrationEvidence;
181
+ if (parsed.schemaVersion !== 1 || parsed.kind !== 'call' || !parsed.label || !parsed.toolName) {
182
+ throw new Error('invalid live integration evidence record');
183
+ }
184
+ evidence.push(parsed);
185
+ }
186
+ return evidence;
187
+ }
@@ -66,6 +66,10 @@ interface CompileOptions {
66
66
  * skips its own triageRequests() LLM call and merges the shared selectedSeqs
67
67
  * with any per-tool preserveSeqs locally. */
68
68
  preTriagedSession?: TriageResult;
69
+ /** Authoritative, post-verification workflow contract. Playbook compilation
70
+ * uses this instead of re-inferring the public parameter surface from the
71
+ * recording candidate. */
72
+ workflow?: Workflow;
69
73
  }
70
74
 
71
75
  // ─── generate (workflow.json) ────────────────────────────────────────────────
@@ -93,6 +97,8 @@ interface GenerateOptions extends CompileOptions {
93
97
  * response parsing, shared-module imports). Injected into the agent's initial
94
98
  * message so the compile follows it. */
95
99
  toolPlan?: string;
100
+ /** Bounded teach resume: revise the current artifact from durable feedback. */
101
+ revisionMode?: boolean;
96
102
  }
97
103
 
98
104
  interface GenerateResult {
@@ -136,6 +142,7 @@ export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
136
142
  buildPlanPath: opts.buildPlanPath,
137
143
  sharedModules: opts.sharedModules,
138
144
  toolPlan: opts.toolPlan,
145
+ revisionMode: opts.revisionMode,
139
146
  });
140
147
 
141
148
  setSpanAttributes(span, {
@@ -216,6 +223,10 @@ function relocateGeneratedWorkflow(workflowPath: string, workflow: Workflow): st
216
223
  '.compile-log.json',
217
224
  '.compile-done.json',
218
225
  '.compile-give-up.json',
226
+ '.live-verifier-log.jsonl',
227
+ '.live-verification-evidence.json',
228
+ '.live-verification.json',
229
+ 'backends.json',
219
230
  ]) {
220
231
  const source = pathJoin(sourceDir, artifact);
221
232
  if (!existsSync(source)) continue;
@@ -791,10 +802,18 @@ async function compilePlaybookImpl(opts: CompileOptions): Promise<CompilePlayboo
791
802
  `compiling playbook from ${session.events.length} events / ${xhrs.length} XHRs / ${session.narration.length} narration lines…`,
792
803
  );
793
804
 
805
+ const workflowContract = opts.workflow
806
+ ? {
807
+ toolName: opts.workflow.toolName,
808
+ parameters: opts.workflow.parameters,
809
+ limitations: opts.workflow.limitations ?? [],
810
+ }
811
+ : undefined;
794
812
  const slimmed = {
795
813
  site: session.site,
796
814
  url: compactUrlForLlm(session.url),
797
815
  candidate: opts.candidate,
816
+ workflowContract,
798
817
  sharedContext: opts.sharedContext,
799
818
  narration: session.narration,
800
819
  events: session.events,
@@ -812,6 +831,10 @@ async function compilePlaybookImpl(opts: CompileOptions): Promise<CompilePlayboo
812
831
  opts.candidate
813
832
  ? `\n\nCandidate scope:\nCompile only this candidate: ${JSON.stringify(opts.candidate, null, 2)}\nShared context: ${JSON.stringify(opts.sharedContext ?? {}, null, 2)}\nThe playbook toolName and parameters must match the selected candidate/workflow, not any other action in the recording.\n`
814
833
  : ''
834
+ }${
835
+ workflowContract
836
+ ? `\n\nAuthoritative verified workflow contract:\n${JSON.stringify(workflowContract, null, 2)}\nThis contract supersedes candidate likelyParams and raw recording branches. Use exactly these public parameters. Never resurrect an omitted parameter or reference it in a step. When a limitation omits a recorded branch, leave that branch out of the playbook and retain the limitation in notes if useful.\n`
837
+ : ''
815
838
  }`;
816
839
 
817
840
  const llm = resolveProvider(opts.llmConfig ?? {});
@@ -825,13 +848,21 @@ async function compilePlaybookImpl(opts: CompileOptions): Promise<CompilePlayboo
825
848
  for (let attempt = 0; attempt < 2; attempt++) {
826
849
  try {
827
850
  playbook = parsePlaybook(stripCodeFences(lastResult.text).trim());
851
+ if (opts.workflow) {
852
+ const contractFailures = playbookWorkflowContractFailures(playbook, opts.workflow);
853
+ if (contractFailures.length > 0) {
854
+ throw new Error(
855
+ `playbook conflicts with the authoritative verified workflow contract:\n- ${contractFailures.join('\n- ')}`,
856
+ );
857
+ }
858
+ }
828
859
  lastErr = undefined;
829
860
  break;
830
861
  } catch (err) {
831
862
  lastErr = err;
832
863
  if (attempt === 0) {
833
- log('playbook YAML failed to parse, retrying with error feedback…');
834
- const fixPrompt = `Your previous output was invalid YAML. The parser error was:\n\n${err instanceof Error ? err.message : String(err)}\n\nFix the YAML and return the corrected playbook. Output ONLY valid YAML, no prose.`;
864
+ log('playbook failed validation, retrying with error feedback…');
865
+ const fixPrompt = `Your previous playbook was invalid. The validation error was:\n\n${err instanceof Error ? err.message : String(err)}\n\nReason about the verified workflow contract, fix the YAML, and return the corrected playbook. Output ONLY valid YAML, no prose.`;
835
866
  lastResult = await llm.analyze(systemPrompt, `${JSON.stringify(slimmed)}\n\n${fixPrompt}`);
836
867
  llmInputTokens = addNullable(llmInputTokens, lastResult.inputTokens);
837
868
  llmOutputTokens = addNullable(llmOutputTokens, lastResult.outputTokens);
@@ -868,6 +899,44 @@ async function compilePlaybookImpl(opts: CompileOptions): Promise<CompilePlayboo
868
899
  };
869
900
  }
870
901
 
902
+ /**
903
+ * Check only the compatibility boundary required for API→playbook fallback.
904
+ * The playbook agent remains free to choose DOM actions and locators, but it
905
+ * cannot change the verified public tool surface or depend on hidden inputs.
906
+ */
907
+ export function playbookWorkflowContractFailures(playbook: Playbook, workflow: Workflow): string[] {
908
+ const failures: string[] = [];
909
+ if (playbook.toolName !== workflow.toolName) {
910
+ failures.push(
911
+ `toolName ${JSON.stringify(playbook.toolName)} must equal ${JSON.stringify(workflow.toolName)}`,
912
+ );
913
+ }
914
+
915
+ const workflowNames = new Set(workflow.parameters.map((parameter) => parameter.name));
916
+ const playbookNames = new Set(playbook.parameters.map((parameter) => parameter.name));
917
+ const missing = [...workflowNames].filter((name) => !playbookNames.has(name)).sort();
918
+ const unexpected = [...playbookNames].filter((name) => !workflowNames.has(name)).sort();
919
+ if (missing.length > 0) {
920
+ failures.push(`missing public parameter(s): ${missing.join(', ')}`);
921
+ }
922
+ if (unexpected.length > 0) {
923
+ failures.push(`unexpected parameter(s): ${unexpected.join(', ')}`);
924
+ }
925
+
926
+ const hiddenReferences = new Set<string>();
927
+ const executableText = JSON.stringify({ steps: playbook.steps, result: playbook.result });
928
+ for (const match of executableText.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
929
+ const name = match[1];
930
+ if (name && !workflowNames.has(name)) hiddenReferences.add(name);
931
+ }
932
+ if (hiddenReferences.size > 0) {
933
+ failures.push(
934
+ `step/result references hidden parameter(s): ${[...hiddenReferences].sort().join(', ')}`,
935
+ );
936
+ }
937
+ return failures;
938
+ }
939
+
871
940
  function addNullable(a: number | null, b: number | null): number | null {
872
941
  if (a === null && b === null) return null;
873
942
  return (a ?? 0) + (b ?? 0);
@@ -123,6 +123,14 @@ const PROTECTED_PATTERNS = [
123
123
  /\b[0-9a-f]{40}\b/gi,
124
124
  ];
125
125
 
126
+ // UUIDs and 40-hex identifiers are normally protected from generic secret
127
+ // heuristics because they are common, benign IDs in recorded payloads. When
128
+ // the same shapes occur immediately in an authentication context, however,
129
+ // that context is the useful signal: redact only the value while continuing to
130
+ // preserve identical bare IDs elsewhere.
131
+ const CONTEXTUAL_PROTECTED_SECRET_RE =
132
+ /(\b(?:authorization|proxy-authorization)\b["']?\s*[:=]\s*["']?(?:bearer\s+)?|\bbearer\s+|\b(?:oauth(?:2)?(?:[_ -]?access)?[_ -]?token|access[_ -]?token)\b["']?\s*[:=]\s*["']?)([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|[0-9a-f]{40})(?=["'\s,;&)}\]]|$)/gi;
133
+
126
134
  const REDACTOR = createRedactum({
127
135
  policies: FREEFORM_POLICIES,
128
136
  replacement: () => '[REDACTED]',
@@ -145,24 +153,37 @@ export function redactFreeformText(text: string): FreeformRedaction {
145
153
  return { redacted: text, redactionsCount: 0 };
146
154
  }
147
155
 
148
- const protectedRanges = collectProtectedRanges(text);
156
+ let contextualRedactions = 0;
157
+ const contextuallyRedacted = text.replace(
158
+ CONTEXTUAL_PROTECTED_SECRET_RE,
159
+ (_match, prefix: string) => {
160
+ contextualRedactions++;
161
+ return `${prefix}[REDACTED]`;
162
+ },
163
+ );
164
+
165
+ const protectedRanges = collectProtectedRanges(contextuallyRedacted);
149
166
  if (protectedRanges.length === 0) {
150
- return redactUnprotectedText(text);
167
+ const result = redactUnprotectedText(contextuallyRedacted);
168
+ return {
169
+ redacted: result.redacted,
170
+ redactionsCount: contextualRedactions + result.redactionsCount,
171
+ };
151
172
  }
152
173
 
153
174
  let redacted = '';
154
175
  let cursor = 0;
155
- let redactionsCount = 0;
176
+ let redactionsCount = contextualRedactions;
156
177
  for (const range of protectedRanges) {
157
- const segment = text.slice(cursor, range.start);
178
+ const segment = contextuallyRedacted.slice(cursor, range.start);
158
179
  const segmentResult = redactUnprotectedText(segment);
159
180
  redacted += segmentResult.redacted;
160
181
  redactionsCount += segmentResult.redactionsCount;
161
- redacted += text.slice(range.start, range.end);
182
+ redacted += contextuallyRedacted.slice(range.start, range.end);
162
183
  cursor = range.end;
163
184
  }
164
185
 
165
- const tail = redactUnprotectedText(text.slice(cursor));
186
+ const tail = redactUnprotectedText(contextuallyRedacted.slice(cursor));
166
187
  redacted += tail.redacted;
167
188
  redactionsCount += tail.redactionsCount;
168
189