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
@@ -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);
@@ -120,12 +120,8 @@ class KeyringBackend implements CredentialBackend {
120
120
 
121
121
  async getSecret(site: string, name: string): Promise<string | null> {
122
122
  const entry = new this.Entry(SERVICE_NAME, this.accountFor(site, name));
123
- try {
124
- const v = entry.getPassword();
125
- return typeof v === 'string' && v.length > 0 ? v : null;
126
- } catch {
127
- return null;
128
- }
123
+ const v = entry.getPassword();
124
+ return typeof v === 'string' && v.length > 0 ? v : null;
129
125
  }
130
126
 
131
127
  async setSecret(site: string, name: string, value: string): Promise<void> {
@@ -135,11 +131,7 @@ class KeyringBackend implements CredentialBackend {
135
131
 
136
132
  async deleteSecret(site: string, name: string): Promise<void> {
137
133
  const entry = new this.Entry(SERVICE_NAME, this.accountFor(site, name));
138
- try {
139
- entry.deletePassword();
140
- } catch {
141
- // Already absent — fine.
142
- }
134
+ entry.deletePassword();
143
135
  }
144
136
 
145
137
  async listSecrets(site: string): Promise<string[]> {
@@ -157,23 +149,15 @@ class KeyringBackend implements CredentialBackend {
157
149
 
158
150
  async getCookies(site: string): Promise<CookieRecord[]> {
159
151
  const entry = new this.Entry(SERVICE_NAME, this.cookieAccount(site));
160
- try {
161
- const v = entry.getPassword();
162
- if (typeof v !== 'string' || v.length === 0) return [];
163
- return JSON.parse(v) as CookieRecord[];
164
- } catch {
165
- return [];
166
- }
152
+ const v = entry.getPassword();
153
+ if (typeof v !== 'string' || v.length === 0) return [];
154
+ return JSON.parse(v) as CookieRecord[];
167
155
  }
168
156
 
169
157
  async setCookies(site: string, cookies: CookieRecord[]): Promise<void> {
170
158
  const entry = new this.Entry(SERVICE_NAME, this.cookieAccount(site));
171
159
  if (cookies.length === 0) {
172
- try {
173
- entry.deletePassword();
174
- } catch {
175
- /* ignore */
176
- }
160
+ entry.deletePassword();
177
161
  return;
178
162
  }
179
163
  entry.setPassword(JSON.stringify(cookies));
@@ -181,23 +165,15 @@ class KeyringBackend implements CredentialBackend {
181
165
 
182
166
  async getStorage(site: string): Promise<StorageRecord[]> {
183
167
  const entry = new this.Entry(SERVICE_NAME, this.storageAccount(site));
184
- try {
185
- const v = entry.getPassword();
186
- if (typeof v !== 'string' || v.length === 0) return [];
187
- return JSON.parse(v) as StorageRecord[];
188
- } catch {
189
- return [];
190
- }
168
+ const v = entry.getPassword();
169
+ if (typeof v !== 'string' || v.length === 0) return [];
170
+ return JSON.parse(v) as StorageRecord[];
191
171
  }
192
172
 
193
173
  async setStorage(site: string, storage: StorageRecord[]): Promise<void> {
194
174
  const entry = new this.Entry(SERVICE_NAME, this.storageAccount(site));
195
175
  if (storage.length === 0) {
196
- try {
197
- entry.deletePassword();
198
- } catch {
199
- /* ignore */
200
- }
176
+ entry.deletePassword();
201
177
  return;
202
178
  }
203
179
  entry.setPassword(JSON.stringify(storage));
@@ -713,19 +689,56 @@ export async function saveSiteCookies(site: string, cookies: CookieRecord[]): Pr
713
689
  await backend.setCookies(site, cookies);
714
690
  }
715
691
 
716
- /** Convenience wrapper to persist browser storage (localStorage/sessionStorage)
717
- * after a successful auth tool run, so a later stateless `submit_otp` call can
718
- * rehydrate the same session state. Delegates to the backend's optional
719
- * setStorage; no-ops on backends that don't support storage. */
692
+ /** Persist browser storage through the active credential backend. */
720
693
  export async function saveSiteStorage(site: string, storage: StorageRecord[]): Promise<void> {
721
694
  const backend = await getCredentialBackend();
722
695
  await backend.setStorage?.(site, storage);
723
696
  }
724
697
 
725
- /** Persist a single durable secret value (e.g. a bearer/access token captured
726
- * from a completed login via authConfig.sessionCapture) so data tools reuse it
727
- * as `${credential.NAME}` without re-running auth. */
728
- export async function saveSiteSecret(site: string, name: string, value: string): Promise<void> {
698
+ /** Replace the durable auth material as one logical commit. Backends such as
699
+ * the OS keyring do not expose transactions, so restore the complete prior
700
+ * snapshot if any write fails rather than leaving mixed-session state. */
701
+ export async function commitSiteAuthState(
702
+ site: string,
703
+ cookies: CookieRecord[],
704
+ secrets: Record<string, string>,
705
+ ): Promise<void> {
729
706
  const backend = await getCredentialBackend();
730
- await backend.setSecret(site, name, value);
707
+ const previousCookies = await backend.getCookies(site);
708
+ const previousSecrets = new Map<string, string | null>();
709
+ for (const name of Object.keys(secrets)) {
710
+ previousSecrets.set(name, await backend.getSecret(site, name));
711
+ }
712
+
713
+ try {
714
+ await backend.setCookies(site, cookies);
715
+ for (const [name, value] of Object.entries(secrets)) {
716
+ await backend.setSecret(site, name, value);
717
+ }
718
+ } catch (err) {
719
+ const rollbackFailures: string[] = [];
720
+ try {
721
+ await backend.setCookies(site, previousCookies);
722
+ } catch (rollbackErr) {
723
+ rollbackFailures.push(
724
+ `cookies: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
725
+ );
726
+ }
727
+ for (const [name, value] of previousSecrets) {
728
+ try {
729
+ if (value === null) await backend.deleteSecret(site, name);
730
+ else await backend.setSecret(site, name, value);
731
+ } catch (rollbackErr) {
732
+ rollbackFailures.push(
733
+ `${name}: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
734
+ );
735
+ }
736
+ }
737
+ const original = err instanceof Error ? err.message : String(err);
738
+ throw new Error(
739
+ rollbackFailures.length > 0
740
+ ? `${original}; rollback also failed for ${rollbackFailures.join(', ')}`
741
+ : original,
742
+ );
743
+ }
731
744
  }
@@ -107,6 +107,7 @@ import { fileURLToPath } from 'node:url';
107
107
  import { dirname, join } from 'node:path';
108
108
  import {
109
109
  executeWorkflow,
110
+ type BrowserNavigationTransport,
110
111
  type CredentialStore,
111
112
  } from 'imprint/runtime';
112
113
  import type { ToolResult, Workflow } from 'imprint/types';
@@ -119,7 +120,7 @@ ${paramTypeFields}
119
120
 
120
121
  export async function ${camelCase(workflow.toolName)}(
121
122
  ${workflow.parameters.length === 0 ? '_input' : 'input'}: ${pascalCase(workflow.toolName)}Input,
122
- opts: { credentials?: CredentialStore; fetchImpl?: typeof fetch; initialState?: Record<string, unknown> } = {},
123
+ opts: { credentials?: CredentialStore; fetchImpl?: typeof fetch; browser?: BrowserNavigationTransport; initialState?: Record<string, unknown> } = {},
123
124
  ): Promise<ToolResult> {
124
125
  const __dirname = dirname(fileURLToPath(import.meta.url));
125
126
  const params: Record<string, string | number | boolean> = {
@@ -131,6 +132,7 @@ ${defaultsBlock && requiredCopies ? requiredCopies : ''}
131
132
  params,
132
133
  credentials: opts.credentials,
133
134
  fetchImpl: opts.fetchImpl,
135
+ browser: opts.browser,
134
136
  initialState: opts.initialState,
135
137
  workflowPath: join(__dirname, 'workflow.json'),
136
138
  });
@@ -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