imprint-mcp 0.6.4 → 0.6.6
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.
- package/README.md +5 -0
- package/package.json +1 -1
- package/prompts/audit-agent.md +3 -3
- package/prompts/auth-compile-agent.md +46 -4
- package/prompts/compile-agent.md +36 -12
- package/prompts/live-verifier-agent.md +15 -5
- package/prompts/request-triage.md +51 -9
- package/prompts/tool-candidate-detection.md +7 -1
- package/prompts/tool-planning.md +1 -1
- package/src/cli.ts +71 -13
- package/src/imprint/audit.ts +23 -5
- package/src/imprint/auth-compile-agent.ts +11 -7
- package/src/imprint/auth-compile-tools.ts +127 -18
- package/src/imprint/auth-verifier.ts +2 -1
- package/src/imprint/backend-ladder.ts +20 -0
- package/src/imprint/build-plan.ts +21 -0
- package/src/imprint/claude-cli-compile.ts +7 -1
- package/src/imprint/codex-cli-compile.ts +6 -0
- package/src/imprint/compile-agent.ts +50 -9
- package/src/imprint/compile-tools.ts +308 -25
- package/src/imprint/compile-verification.ts +11 -2
- package/src/imprint/compile.ts +298 -69
- package/src/imprint/effects.ts +9 -0
- package/src/imprint/live-verifier.ts +455 -34
- package/src/imprint/login.ts +30 -12
- package/src/imprint/mcp-compile-server.ts +56 -5
- package/src/imprint/mcp-live-verifier-server.ts +43 -10
- package/src/imprint/probe-backends.ts +6 -0
- package/src/imprint/recording-request.ts +7 -2
- package/src/imprint/replay-capture.ts +48 -29
- package/src/imprint/request-capture.ts +14 -0
- package/src/imprint/runtime.ts +102 -22
- package/src/imprint/teach-state.ts +162 -21
- package/src/imprint/teach.ts +629 -226
- package/src/imprint/tool-candidates.ts +138 -0
- package/src/imprint/tool-plan.ts +2 -0
- package/src/imprint/triage-selection.ts +44 -0
- package/src/imprint/types.ts +39 -3
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
readLiveIntegrationEvidence,
|
|
36
36
|
} from './compile-verification.ts';
|
|
37
37
|
import { splitSetCookieHeader } from './cookie-jar.ts';
|
|
38
|
+
import { isIrreversibleRequest, workflowHasIrreversibleEffect } from './effects.ts';
|
|
38
39
|
import { isSameRegistrableDomain, registrableDomain } from './etld.ts';
|
|
39
40
|
import {
|
|
40
41
|
endpointsForSeqs,
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
} from './param-grounding.ts';
|
|
45
46
|
import { detectPageMintedHeaders } from './redact.ts';
|
|
46
47
|
import { compactRequestContexts, requestContextDigest } from './request-context.ts';
|
|
48
|
+
import { ensureImprintRuntimeLink } from './runtime-link.ts';
|
|
47
49
|
import { isSensitiveHeader } from './sensitive-keys.ts';
|
|
48
50
|
import { type ClassifiedValue, looksLikeToken } from './session-diff.ts';
|
|
49
51
|
import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
|
|
@@ -53,11 +55,40 @@ import {
|
|
|
53
55
|
type RequestCapture,
|
|
54
56
|
type Session,
|
|
55
57
|
SessionSchema,
|
|
58
|
+
type Workflow,
|
|
56
59
|
WorkflowSchema,
|
|
57
60
|
} from './types.ts';
|
|
58
61
|
|
|
59
62
|
const REPO_ROOT = pathJoin(import.meta.dir, '..', '..');
|
|
60
63
|
const MAX_SHARED_MODULE_SOURCE_CHARS = 50_000;
|
|
64
|
+
const RUNTIME_BODY_PLACEHOLDER =
|
|
65
|
+
/\$\{(?:(?:param|credential|response|generated|env)\.|(?:state|cookie)(?:\.|\["))/;
|
|
66
|
+
|
|
67
|
+
export function requestsNeedingBodyEncodingDecision(workflow: Workflow): number[] {
|
|
68
|
+
return workflow.requests.flatMap((request, index) =>
|
|
69
|
+
request.body && RUNTIME_BODY_PLACEHOLDER.test(request.body) ? [index] : [],
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Compile-time contract only; legacy workflows retain runtime inference. */
|
|
74
|
+
export function bodyEncodingContractFailures(workflow: Workflow): string[] {
|
|
75
|
+
return requestsNeedingBodyEncodingDecision(workflow)
|
|
76
|
+
.filter((index) => workflow.requests[index]?.bodyPlaceholderEncoding === undefined)
|
|
77
|
+
.map(
|
|
78
|
+
(index) =>
|
|
79
|
+
`request ${index} has runtime placeholders in its body but no bodyPlaceholderEncoding. Inspect the recorded wire format, choose raw, json-string, or form-urlencoded, then prove the choice with an agent-authored offline request.test.ts.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function requestEncodingTestContractFailures(
|
|
84
|
+
workflow: Workflow,
|
|
85
|
+
source: string | undefined,
|
|
86
|
+
): string[] {
|
|
87
|
+
if (requestsNeedingBodyEncodingDecision(workflow).length === 0) return [];
|
|
88
|
+
return source === undefined
|
|
89
|
+
? ['request.test.ts is required for a placeholder-bearing request body']
|
|
90
|
+
: [];
|
|
91
|
+
}
|
|
61
92
|
|
|
62
93
|
// Env var read by the agent-written parser.test.ts to locate the redacted
|
|
63
94
|
// session. The test loads it, finds the load-bearing request seq, and feeds
|
|
@@ -87,6 +118,17 @@ export function buildCompileTools(
|
|
|
87
118
|
const credEnv = context.teachCredentials
|
|
88
119
|
? { IMPRINT_TEACH_CREDENTIALS: JSON.stringify(context.teachCredentials) }
|
|
89
120
|
: undefined;
|
|
121
|
+
const candidateSeqs = context.candidate
|
|
122
|
+
? new Set([
|
|
123
|
+
...context.candidate.requestSeqs,
|
|
124
|
+
...context.candidate.dependencySeqs,
|
|
125
|
+
...(context.sharedContext?.loginRequestSeqs ?? []),
|
|
126
|
+
])
|
|
127
|
+
: undefined;
|
|
128
|
+
const hasIrreversibleCandidateRequest = session.requests.some(
|
|
129
|
+
(request) =>
|
|
130
|
+
isIrreversibleRequest(request) && (!candidateSeqs || candidateSeqs.has(request.seq)),
|
|
131
|
+
);
|
|
90
132
|
const tools = [
|
|
91
133
|
buildReadSessionSummaryTool(session, context, toolDir),
|
|
92
134
|
buildReadRequestTool(session),
|
|
@@ -97,9 +139,15 @@ export function buildCompileTools(
|
|
|
97
139
|
buildSearchResponseBodyTool(session),
|
|
98
140
|
buildWriteFileTool(toolDir),
|
|
99
141
|
buildReadFileTool(toolDir),
|
|
100
|
-
buildRunBashTool(toolDir, credEnv),
|
|
101
|
-
buildRunTestsTool(toolDir, sessionPath, credEnv),
|
|
102
142
|
];
|
|
143
|
+
tools.push(
|
|
144
|
+
buildRunTestsTool(toolDir, sessionPath, credEnv, {
|
|
145
|
+
networkDisabled: hasIrreversibleCandidateRequest,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
if (!hasIrreversibleCandidateRequest) {
|
|
149
|
+
tools.push(buildRunBashTool(toolDir, credEnv));
|
|
150
|
+
}
|
|
103
151
|
if (context.buildPlanPath && context.candidate?.toolName) {
|
|
104
152
|
tools.push(
|
|
105
153
|
buildReadBuildPlanTool(
|
|
@@ -346,6 +394,7 @@ export function buildReadSessionSummaryTool(
|
|
|
346
394
|
mimeType: r.response?.mimeType,
|
|
347
395
|
bodySize: r.response?.body?.length,
|
|
348
396
|
responseBodyDigest: requestContextDigest(r.response?.body),
|
|
397
|
+
...(isIrreversibleRequest(r) ? { irreversible: true } : {}),
|
|
349
398
|
...(preserveSeqs.has(r.seq) ? { inlineData: buildInlineData(r) } : {}),
|
|
350
399
|
})),
|
|
351
400
|
compileSummaryRequestGroupKey,
|
|
@@ -370,6 +419,7 @@ export function buildReadSessionSummaryTool(
|
|
|
370
419
|
? context.candidate.representativeSeqs
|
|
371
420
|
: context.candidate.requestSeqs,
|
|
372
421
|
dependencySeqs: context.candidate.dependencySeqs,
|
|
422
|
+
dependsOnTools: context.candidate.dependsOnTools,
|
|
373
423
|
eventSeqs: context.candidate.eventSeqs,
|
|
374
424
|
likelyParams: context.candidate.likelyParams,
|
|
375
425
|
}
|
|
@@ -406,6 +456,7 @@ export function buildReadSessionSummaryTool(
|
|
|
406
456
|
const REVISION_ARTIFACT_NAMES = [
|
|
407
457
|
'workflow.json',
|
|
408
458
|
'parser.ts',
|
|
459
|
+
'request.test.ts',
|
|
409
460
|
'request-transform.ts',
|
|
410
461
|
'integration.test.ts',
|
|
411
462
|
'playbook.yaml',
|
|
@@ -905,6 +956,7 @@ interface CompileSummaryRequestContext {
|
|
|
905
956
|
mimeType?: string;
|
|
906
957
|
bodySize?: number;
|
|
907
958
|
responseBodyDigest?: string;
|
|
959
|
+
irreversible?: boolean;
|
|
908
960
|
repeatCount?: number;
|
|
909
961
|
repeatedSeqs?: number[];
|
|
910
962
|
lastTimestamp?: number;
|
|
@@ -918,6 +970,7 @@ function compileSummaryRequestGroupKey(request: CompileSummaryRequestContext): u
|
|
|
918
970
|
request.mimeType,
|
|
919
971
|
request.bodySize,
|
|
920
972
|
request.responseBodyDigest,
|
|
973
|
+
request.irreversible,
|
|
921
974
|
];
|
|
922
975
|
}
|
|
923
976
|
|
|
@@ -1306,6 +1359,7 @@ export function buildWriteFileTool(toolDir: string, extraAllowed: string[] = [])
|
|
|
1306
1359
|
'workflow.json',
|
|
1307
1360
|
'parser.ts',
|
|
1308
1361
|
'parser.test.ts',
|
|
1362
|
+
'request.test.ts',
|
|
1309
1363
|
'request-transform.ts',
|
|
1310
1364
|
'integration.test.ts',
|
|
1311
1365
|
...extraAllowed,
|
|
@@ -1412,7 +1466,7 @@ export function buildReadFileTool(toolDir: string): AgentTool {
|
|
|
1412
1466
|
|
|
1413
1467
|
// ─── Tool: run_bash ──────────────────────────────────────────────────────────
|
|
1414
1468
|
|
|
1415
|
-
|
|
1469
|
+
function buildRunBashTool(toolDir: string, credEnv?: Record<string, string>): AgentTool {
|
|
1416
1470
|
return {
|
|
1417
1471
|
name: 'run_bash',
|
|
1418
1472
|
description: 'Run a shell command in the generated tool directory with a timeout.',
|
|
@@ -1435,7 +1489,6 @@ export function buildRunBashTool(toolDir: string, credEnv?: Record<string, strin
|
|
|
1435
1489
|
}
|
|
1436
1490
|
|
|
1437
1491
|
const cappedTimeout = Math.min(timeoutSec, 300) * 1000;
|
|
1438
|
-
|
|
1439
1492
|
return await runCommand(command, toolDir, cappedTimeout, credEnv);
|
|
1440
1493
|
},
|
|
1441
1494
|
};
|
|
@@ -1554,6 +1607,13 @@ export async function typecheckArtifacts(
|
|
|
1554
1607
|
extends: extendsPath,
|
|
1555
1608
|
include: includes,
|
|
1556
1609
|
exclude: ['*.test.ts'],
|
|
1610
|
+
compilerOptions: {
|
|
1611
|
+
// Generated tools normally live under ~/.imprint, outside the repo's
|
|
1612
|
+
// node_modules ancestry. Extending the repo tsconfig does not change
|
|
1613
|
+
// where TypeScript searches for named `types`, so point that lookup
|
|
1614
|
+
// at the dependencies of the runtime being compiled.
|
|
1615
|
+
typeRoots: [pathJoin(REPO_ROOT, 'node_modules', '@types')],
|
|
1616
|
+
},
|
|
1557
1617
|
},
|
|
1558
1618
|
null,
|
|
1559
1619
|
2,
|
|
@@ -1589,30 +1649,60 @@ function normalizeTsconfigPath(value: string): string {
|
|
|
1589
1649
|
|
|
1590
1650
|
// ─── Tool: run_tests ─────────────────────────────────────────────────────────
|
|
1591
1651
|
|
|
1592
|
-
function buildRunTestsTool(
|
|
1652
|
+
export function buildRunTestsTool(
|
|
1593
1653
|
toolDir: string,
|
|
1594
1654
|
sessionPath: string,
|
|
1595
1655
|
credEnv?: Record<string, string>,
|
|
1656
|
+
opts: { networkDisabled?: boolean } = {},
|
|
1596
1657
|
): AgentTool {
|
|
1597
1658
|
return {
|
|
1598
1659
|
name: 'run_tests',
|
|
1599
1660
|
description:
|
|
1600
|
-
'Run
|
|
1661
|
+
'Run the agent-written parser.test.ts and/or request.test.ts plus strict TypeScript checks for generated parser/request-transform artifacts, then report pass/fail counts.',
|
|
1601
1662
|
input_schema: {
|
|
1602
1663
|
type: 'object',
|
|
1603
1664
|
properties: {},
|
|
1604
1665
|
required: [],
|
|
1605
1666
|
},
|
|
1606
1667
|
handler: async () => {
|
|
1607
|
-
const
|
|
1608
|
-
|
|
1668
|
+
const testNames = ['parser.test.ts', 'request.test.ts'].filter((name) =>
|
|
1669
|
+
existsSync(pathJoin(toolDir, name)),
|
|
1670
|
+
);
|
|
1671
|
+
if (testNames.length === 0) {
|
|
1609
1672
|
return {
|
|
1610
|
-
result: '
|
|
1673
|
+
result: 'no generated tests exist — write parser.test.ts and/or request.test.ts first',
|
|
1611
1674
|
isError: true,
|
|
1612
1675
|
};
|
|
1613
1676
|
}
|
|
1614
1677
|
|
|
1615
|
-
|
|
1678
|
+
// Agent tests execute from ~/.imprint/<site>/<tool>. Ensure their
|
|
1679
|
+
// `imprint/*` imports resolve to the runtime currently doing the compile,
|
|
1680
|
+
// rather than a stale global install or vanished worktree.
|
|
1681
|
+
const imprintHome = pathJoin(toolDir, '..', '..');
|
|
1682
|
+
ensureImprintRuntimeLink(dirname(imprintHome) !== imprintHome ? imprintHome : toolDir);
|
|
1683
|
+
|
|
1684
|
+
let workflowRequiresIsolation = false;
|
|
1685
|
+
try {
|
|
1686
|
+
workflowRequiresIsolation = workflowHasIrreversibleEffect(
|
|
1687
|
+
WorkflowSchema.parse(
|
|
1688
|
+
JSON.parse(readFileSync(pathJoin(toolDir, 'workflow.json'), 'utf8')),
|
|
1689
|
+
),
|
|
1690
|
+
);
|
|
1691
|
+
} catch {
|
|
1692
|
+
// Workflow/schema failures are reported by the compile verifier.
|
|
1693
|
+
}
|
|
1694
|
+
const testCommand = networkIsolatedCommand(
|
|
1695
|
+
`bun test ${testNames.join(' ')}`,
|
|
1696
|
+
opts.networkDisabled === true || workflowRequiresIsolation,
|
|
1697
|
+
);
|
|
1698
|
+
if (!testCommand) {
|
|
1699
|
+
return {
|
|
1700
|
+
result:
|
|
1701
|
+
'offline tests require a supported network-isolation runner for an irreversible workflow',
|
|
1702
|
+
isError: true,
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
const cmdResult = await runCommand(testCommand, toolDir, 120000, {
|
|
1616
1706
|
[SESSION_PATH_ENV]: sessionPath,
|
|
1617
1707
|
...credEnv,
|
|
1618
1708
|
});
|
|
@@ -1624,13 +1714,20 @@ function buildRunTestsTool(
|
|
|
1624
1714
|
timedOut: boolean;
|
|
1625
1715
|
};
|
|
1626
1716
|
|
|
1627
|
-
const
|
|
1628
|
-
const
|
|
1717
|
+
const testOutput = `${output.stdout}\n${output.stderr}`;
|
|
1718
|
+
const passMatch = testOutput.match(/(\d+)\s+pass/);
|
|
1719
|
+
const failMatch = testOutput.match(/(\d+)\s+fail/);
|
|
1629
1720
|
|
|
1630
1721
|
const passed = passMatch?.[1] ? Number.parseInt(passMatch[1], 10) : 0;
|
|
1631
1722
|
const failed = failMatch?.[1] ? Number.parseInt(failMatch[1], 10) : 0;
|
|
1632
1723
|
const total = passed + failed;
|
|
1633
|
-
const
|
|
1724
|
+
const typecheckIncludes = ['parser.ts', 'request-transform.ts'].filter((name) =>
|
|
1725
|
+
existsSync(pathJoin(toolDir, name)),
|
|
1726
|
+
);
|
|
1727
|
+
const typecheck =
|
|
1728
|
+
typecheckIncludes.length > 0
|
|
1729
|
+
? await typecheckArtifacts(toolDir, typecheckIncludes)
|
|
1730
|
+
: { stdout: '', stderr: '', exitCode: 0, timedOut: false };
|
|
1634
1731
|
const typecheckFailed = typecheck.exitCode !== 0 || typecheck.timedOut;
|
|
1635
1732
|
|
|
1636
1733
|
return {
|
|
@@ -1655,6 +1752,20 @@ function buildRunTestsTool(
|
|
|
1655
1752
|
};
|
|
1656
1753
|
}
|
|
1657
1754
|
|
|
1755
|
+
function networkIsolatedCommand(command: string, disabled: boolean): string | null {
|
|
1756
|
+
if (!disabled) return command;
|
|
1757
|
+
if (process.platform === 'darwin' && existsSync('/usr/bin/sandbox-exec')) {
|
|
1758
|
+
return `/usr/bin/sandbox-exec -p '(version 1) (allow default) (deny network*)' ${command}`;
|
|
1759
|
+
}
|
|
1760
|
+
if (process.platform === 'linux' && existsSync('/usr/bin/bwrap')) {
|
|
1761
|
+
return `/usr/bin/bwrap --ro-bind / / --dev /dev --proc /proc --unshare-net -- ${command}`;
|
|
1762
|
+
}
|
|
1763
|
+
if (process.platform === 'linux' && existsSync('/usr/bin/unshare')) {
|
|
1764
|
+
return `/usr/bin/unshare --user --map-root-user --net -- ${command}`;
|
|
1765
|
+
}
|
|
1766
|
+
return null;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1658
1769
|
// ─── Test-quality helpers (shared with prereq-builder verification) ─────────
|
|
1659
1770
|
|
|
1660
1771
|
/** Tautological assertions that prove nothing — rejected by every verifier so
|
|
@@ -1833,7 +1944,13 @@ export interface ParamVerification {
|
|
|
1833
1944
|
* - `waived-chain`: the param is a producer-sourced token but the producer
|
|
1834
1945
|
* tool could not be run at compile time (anti-bot / not compiled), so the
|
|
1835
1946
|
* chain could not be verified. */
|
|
1836
|
-
reason?:
|
|
1947
|
+
reason?:
|
|
1948
|
+
| 'waived-bot'
|
|
1949
|
+
| 'waived-infra'
|
|
1950
|
+
| 'waived-safety'
|
|
1951
|
+
| 'annotated'
|
|
1952
|
+
| 'waived-chain'
|
|
1953
|
+
| 'semantic-gap';
|
|
1837
1954
|
/** For a producer-sourced token param, the sibling tool + output field its
|
|
1838
1955
|
* value comes from. Stamped into workflow.json (`param.sourcedFrom`) so the
|
|
1839
1956
|
* MCP description tells the orchestrating LLM where to mint it and the audit
|
|
@@ -1865,6 +1982,7 @@ export async function runBunTestWithResults(
|
|
|
1865
1982
|
opts: {
|
|
1866
1983
|
bail?: boolean;
|
|
1867
1984
|
onOutput?: (stream: 'stdout' | 'stderr', chunk: string) => void;
|
|
1985
|
+
networkDisabled?: boolean;
|
|
1868
1986
|
} = {},
|
|
1869
1987
|
): Promise<BunTestRun> {
|
|
1870
1988
|
const junitPath = pathJoin(toolDir, `.imprint-junit-${basename(testPath)}.xml`);
|
|
@@ -1873,13 +1991,21 @@ export async function runBunTestWithResults(
|
|
|
1873
1991
|
} catch {
|
|
1874
1992
|
// best-effort
|
|
1875
1993
|
}
|
|
1876
|
-
const
|
|
1994
|
+
const command = networkIsolatedCommand(
|
|
1877
1995
|
`bun test ${testPath} --reporter=junit --reporter-outfile=${junitPath}${opts.bail ? ' --bail=1' : ''}`,
|
|
1878
|
-
|
|
1879
|
-
timeoutMs,
|
|
1880
|
-
env,
|
|
1881
|
-
opts.onOutput,
|
|
1996
|
+
opts.networkDisabled === true,
|
|
1882
1997
|
);
|
|
1998
|
+
if (!command) {
|
|
1999
|
+
return {
|
|
2000
|
+
stdout: '',
|
|
2001
|
+
stderr: 'network-isolated test runner unavailable',
|
|
2002
|
+
exitCode: 1,
|
|
2003
|
+
timedOut: false,
|
|
2004
|
+
passed: new Set(),
|
|
2005
|
+
failed: new Set(),
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
const result = await runCommand(command, toolDir, timeoutMs, env, opts.onOutput);
|
|
1883
2009
|
const output = JSON.parse(result.result) as {
|
|
1884
2010
|
stdout: string;
|
|
1885
2011
|
stderr: string;
|
|
@@ -2609,7 +2735,11 @@ export function applyParamVerification(
|
|
|
2609
2735
|
export function applyLiveVerification(
|
|
2610
2736
|
toolDir: string,
|
|
2611
2737
|
liveVerification:
|
|
2612
|
-
| {
|
|
2738
|
+
| {
|
|
2739
|
+
kind: 'waived-bot' | 'waived-infra' | 'waived-safety';
|
|
2740
|
+
firstError: string;
|
|
2741
|
+
exhaustedBackends: string[];
|
|
2742
|
+
}
|
|
2613
2743
|
| undefined,
|
|
2614
2744
|
): void {
|
|
2615
2745
|
const workflowPath = pathJoin(toolDir, 'workflow.json');
|
|
@@ -2634,6 +2764,29 @@ export function applyLiveVerification(
|
|
|
2634
2764
|
}
|
|
2635
2765
|
}
|
|
2636
2766
|
|
|
2767
|
+
/** Apply the common compile outcome for workflows that must never be exercised
|
|
2768
|
+
* live. Provider frontends own their transport-specific completion messages,
|
|
2769
|
+
* while this helper owns the shared workflow metadata. */
|
|
2770
|
+
export function applyIrreversibleVerificationWaiver(toolDir: string, workflow: Workflow): string[] {
|
|
2771
|
+
applyLiveVerification(toolDir, {
|
|
2772
|
+
kind: 'waived-safety',
|
|
2773
|
+
firstError: 'live verification is disabled for irreversible workflows',
|
|
2774
|
+
exhaustedBackends: [],
|
|
2775
|
+
});
|
|
2776
|
+
return [
|
|
2777
|
+
'live verification skipped: workflow declares an irreversible request',
|
|
2778
|
+
...applyParamVerification(
|
|
2779
|
+
toolDir,
|
|
2780
|
+
workflow.parameters.map((parameter) => ({
|
|
2781
|
+
name: parameter.name,
|
|
2782
|
+
verified: false,
|
|
2783
|
+
reason: 'waived-safety' as const,
|
|
2784
|
+
...(parameter.sourcedFrom ? { sourcedFrom: parameter.sourcedFrom } : {}),
|
|
2785
|
+
})),
|
|
2786
|
+
),
|
|
2787
|
+
];
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2637
2790
|
/** Strip `${...}` placeholders and query string from a workflow URL so it can
|
|
2638
2791
|
* be compared against a recorded request URL by (origin + path). Returns null
|
|
2639
2792
|
* when the URL is unparseable even after stripping. */
|
|
@@ -2670,6 +2823,55 @@ function findRecordedMatches(
|
|
|
2670
2823
|
});
|
|
2671
2824
|
}
|
|
2672
2825
|
|
|
2826
|
+
export function irreversibleProvenanceFailures(
|
|
2827
|
+
session: Session,
|
|
2828
|
+
workflow: ReturnType<typeof WorkflowSchema.parse>,
|
|
2829
|
+
scope: { candidateRequestSeqs?: number[]; dependencyRequestSeqs?: number[] } = {},
|
|
2830
|
+
): string[] {
|
|
2831
|
+
const scopedSeqs = new Set([
|
|
2832
|
+
...(scope.candidateRequestSeqs ?? []),
|
|
2833
|
+
...(scope.dependencyRequestSeqs ?? []),
|
|
2834
|
+
]);
|
|
2835
|
+
const hasScope = scopedSeqs.size > 0;
|
|
2836
|
+
const failures: string[] = [];
|
|
2837
|
+
|
|
2838
|
+
for (const recorded of session.requests.filter(isIrreversibleRequest)) {
|
|
2839
|
+
if (hasScope && !scopedSeqs.has(recorded.seq)) continue;
|
|
2840
|
+
const generated = workflow.requests
|
|
2841
|
+
.map((request, index) => ({ request, index }))
|
|
2842
|
+
.filter(({ request }) => request.recordingRequestSeq === recorded.seq);
|
|
2843
|
+
if (generated.length === 0) {
|
|
2844
|
+
failures.push(
|
|
2845
|
+
`triage classified recorded request seq ${recorded.seq} as irreversible, but workflow.json has no request with recordingRequestSeq: ${recorded.seq}`,
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
for (const [index, request] of workflow.requests.entries()) {
|
|
2851
|
+
const recorded =
|
|
2852
|
+
request.recordingRequestSeq === undefined
|
|
2853
|
+
? undefined
|
|
2854
|
+
: session.requests.find((candidate) => candidate.seq === request.recordingRequestSeq);
|
|
2855
|
+
if (recorded && isIrreversibleRequest(recorded) && !isIrreversibleRequest(request)) {
|
|
2856
|
+
failures.push(
|
|
2857
|
+
`workflow request index ${index} grounded by recordingRequestSeq ${recorded.seq} must declare effect: "irreversible"`,
|
|
2858
|
+
);
|
|
2859
|
+
}
|
|
2860
|
+
if (!isIrreversibleRequest(request)) continue;
|
|
2861
|
+
if (request.recordingRequestSeq === undefined) {
|
|
2862
|
+
failures.push(
|
|
2863
|
+
`irreversible workflow request index ${index} must include recordingRequestSeq provenance`,
|
|
2864
|
+
);
|
|
2865
|
+
} else if (!session.requests.some((recorded) => recorded.seq === request.recordingRequestSeq)) {
|
|
2866
|
+
failures.push(
|
|
2867
|
+
`irreversible workflow request index ${index} references unknown recordingRequestSeq ${request.recordingRequestSeq}`,
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
return failures;
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2673
2875
|
/** Case-insensitive header lookup against a `Record<string, string>` (which
|
|
2674
2876
|
* records preserve as they were captured — Chrome's DevTools protocol does not
|
|
2675
2877
|
* normalize). */
|
|
@@ -3695,10 +3897,13 @@ export async function externalVerification(
|
|
|
3695
3897
|
// firing it is pure waste that also burns the per-IP anti-bot rate budget.
|
|
3696
3898
|
// Skip the live test in that case and make the agent fix the capture first.
|
|
3697
3899
|
let referencedStateBroken = false;
|
|
3900
|
+
let irreversibleProvenanceMissing = false;
|
|
3901
|
+
let hasIrreversibleWorkflow = false;
|
|
3698
3902
|
|
|
3699
3903
|
const workflowPath = pathJoin(toolDir, 'workflow.json');
|
|
3700
3904
|
const parserPath = pathJoin(toolDir, 'parser.ts');
|
|
3701
3905
|
const parserTestPath = pathJoin(toolDir, 'parser.test.ts');
|
|
3906
|
+
const requestTestPath = pathJoin(toolDir, 'request.test.ts');
|
|
3702
3907
|
const requestTransformPath = pathJoin(toolDir, 'request-transform.ts');
|
|
3703
3908
|
|
|
3704
3909
|
// Contracted-input injection + emit-time secret guard + the contracted-input
|
|
@@ -3763,6 +3968,8 @@ export async function externalVerification(
|
|
|
3763
3968
|
try {
|
|
3764
3969
|
const raw = JSON.parse(readFileSync(workflowPath, 'utf8'));
|
|
3765
3970
|
const workflow = WorkflowSchema.parse(raw);
|
|
3971
|
+
failures.push(...bodyEncodingContractFailures(workflow));
|
|
3972
|
+
hasIrreversibleWorkflow = workflowHasIrreversibleEffect(workflow);
|
|
3766
3973
|
if (opts.expectedToolName && workflow.toolName !== opts.expectedToolName) {
|
|
3767
3974
|
failures.push(
|
|
3768
3975
|
`workflow.toolName "${workflow.toolName}" does not match selected candidate "${opts.expectedToolName}"`,
|
|
@@ -3991,8 +4198,34 @@ export async function externalVerification(
|
|
|
3991
4198
|
failures.push(...assertSharedModuleImports(toolDir, workflowPath, opts.assignedSharedModules));
|
|
3992
4199
|
}
|
|
3993
4200
|
|
|
4201
|
+
// Irreversible-effect provenance: semantic classification belongs to triage
|
|
4202
|
+
// and compile agents. This deterministic gate only proves that their declared
|
|
4203
|
+
// effect stays attached to the exact recording request sequence.
|
|
4204
|
+
if (existsSync(workflowPath)) {
|
|
4205
|
+
try {
|
|
4206
|
+
const wf = WorkflowSchema.parse(JSON.parse(readFileSync(workflowPath, 'utf8')));
|
|
4207
|
+
const irreversibleFailures = irreversibleProvenanceFailures(session, wf, {
|
|
4208
|
+
candidateRequestSeqs: opts.candidateRequestSeqs,
|
|
4209
|
+
dependencyRequestSeqs: opts.dependencyRequestSeqs,
|
|
4210
|
+
});
|
|
4211
|
+
if (irreversibleFailures.length > 0) {
|
|
4212
|
+
irreversibleProvenanceMissing = true;
|
|
4213
|
+
failures.push(...irreversibleFailures);
|
|
4214
|
+
}
|
|
4215
|
+
} catch {
|
|
4216
|
+
/* workflow parse already checked above */
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4220
|
+
const safetyBlocked = hasIrreversibleWorkflow || irreversibleProvenanceMissing;
|
|
4221
|
+
|
|
3994
4222
|
if (!existsSync(parserPath)) {
|
|
3995
4223
|
failures.push('parser.ts was not written');
|
|
4224
|
+
} else if (safetyBlocked) {
|
|
4225
|
+
const parserSource = readFileSync(parserPath, 'utf8');
|
|
4226
|
+
if (!/\bexport\s+(?:async\s+)?function\s+extract\b/.test(parserSource)) {
|
|
4227
|
+
failures.push('parser.ts must export `extract` function');
|
|
4228
|
+
}
|
|
3996
4229
|
} else {
|
|
3997
4230
|
try {
|
|
3998
4231
|
const cacheBust = `?t=${Date.now()}`;
|
|
@@ -4029,9 +4262,17 @@ export async function externalVerification(
|
|
|
4029
4262
|
}
|
|
4030
4263
|
|
|
4031
4264
|
if (existsSync(parserTestPath)) {
|
|
4032
|
-
const run = await runBunTestWithResults(
|
|
4033
|
-
|
|
4034
|
-
|
|
4265
|
+
const run = await runBunTestWithResults(
|
|
4266
|
+
parserTestPath,
|
|
4267
|
+
toolDir,
|
|
4268
|
+
120000,
|
|
4269
|
+
{
|
|
4270
|
+
[SESSION_PATH_ENV]: sessionPath,
|
|
4271
|
+
},
|
|
4272
|
+
{
|
|
4273
|
+
networkDisabled: safetyBlocked,
|
|
4274
|
+
},
|
|
4275
|
+
);
|
|
4035
4276
|
if (run.exitCode !== 0) {
|
|
4036
4277
|
failures.push(
|
|
4037
4278
|
`bun test parser.test.ts exited ${run.exitCode}\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`,
|
|
@@ -4049,6 +4290,38 @@ export async function externalVerification(
|
|
|
4049
4290
|
}
|
|
4050
4291
|
}
|
|
4051
4292
|
|
|
4293
|
+
let workflowForRequestTests: ReturnType<typeof WorkflowSchema.parse> | undefined;
|
|
4294
|
+
try {
|
|
4295
|
+
workflowForRequestTests = WorkflowSchema.parse(JSON.parse(readFileSync(workflowPath, 'utf8')));
|
|
4296
|
+
} catch {
|
|
4297
|
+
// The workflow schema failure above is the actionable error.
|
|
4298
|
+
}
|
|
4299
|
+
if (
|
|
4300
|
+
workflowForRequestTests &&
|
|
4301
|
+
requestsNeedingBodyEncodingDecision(workflowForRequestTests).length > 0
|
|
4302
|
+
) {
|
|
4303
|
+
const src = existsSync(requestTestPath) ? readFileSync(requestTestPath, 'utf8') : undefined;
|
|
4304
|
+
failures.push(...requestEncodingTestContractFailures(workflowForRequestTests, src));
|
|
4305
|
+
if (src !== undefined) {
|
|
4306
|
+
const run = await runBunTestWithResults(
|
|
4307
|
+
requestTestPath,
|
|
4308
|
+
toolDir,
|
|
4309
|
+
120000,
|
|
4310
|
+
{
|
|
4311
|
+
[SESSION_PATH_ENV]: sessionPath,
|
|
4312
|
+
},
|
|
4313
|
+
{
|
|
4314
|
+
networkDisabled: safetyBlocked,
|
|
4315
|
+
},
|
|
4316
|
+
);
|
|
4317
|
+
if (run.exitCode !== 0) {
|
|
4318
|
+
failures.push(
|
|
4319
|
+
`bun test request.test.ts exited ${run.exitCode}\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`,
|
|
4320
|
+
);
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
4324
|
+
|
|
4052
4325
|
// Run the live integration suite and classify the outcome. The per-param
|
|
4053
4326
|
// coverage check below trusts the test *runner* (which named tests actually
|
|
4054
4327
|
// ran green) rather than a static source scan, so a suite that was waived by
|
|
@@ -4056,7 +4329,11 @@ export async function externalVerification(
|
|
|
4056
4329
|
const integrationTestPath = pathJoin(toolDir, 'integration.test.ts');
|
|
4057
4330
|
let integrationOutcome: 'passed' | 'waived-bot' | 'waived-infra' | 'failed' | 'absent' = 'absent';
|
|
4058
4331
|
let integrationPassedTests = new Set<string>();
|
|
4059
|
-
if (
|
|
4332
|
+
if (safetyBlocked) {
|
|
4333
|
+
warnings.push(
|
|
4334
|
+
'live integration suite omitted because the workflow declares an irreversible request',
|
|
4335
|
+
);
|
|
4336
|
+
} else if (!existsSync(integrationTestPath)) {
|
|
4060
4337
|
failures.push(
|
|
4061
4338
|
'integration.test.ts was not written — the tool must include a live API test that calls the workflow and verifies it returns real data',
|
|
4062
4339
|
);
|
|
@@ -4070,6 +4347,11 @@ export async function externalVerification(
|
|
|
4070
4347
|
warnings.push(
|
|
4071
4348
|
'skipped the live integration test: a request references a ${state.X} capture (e.g. csrf/csp-nonce) whose pattern does not match the recorded page, so the live call is guaranteed to fail with STATE_MISSING. Fix the capture pattern/source (see the failure above) — the next verification cycle will run the live test once it can succeed. This avoids burning a doomed anti-bot .act call.',
|
|
4072
4349
|
);
|
|
4350
|
+
} else if (irreversibleProvenanceMissing) {
|
|
4351
|
+
integrationOutcome = 'failed';
|
|
4352
|
+
warnings.push(
|
|
4353
|
+
'skipped the live integration test until workflow.json preserves irreversible request provenance.',
|
|
4354
|
+
);
|
|
4073
4355
|
} else {
|
|
4074
4356
|
const integrationSrc = readFileSync(integrationTestPath, 'utf8');
|
|
4075
4357
|
if (!hasLiveBaselineWorkflowTest(integrationSrc)) {
|
|
@@ -4199,6 +4481,7 @@ export async function externalVerification(
|
|
|
4199
4481
|
// each is recorded in `paramVerification` as verified or not (with a reason),
|
|
4200
4482
|
// and only a genuinely-uncovered param on a suite that DID run blocks compile.
|
|
4201
4483
|
if (
|
|
4484
|
+
!safetyBlocked &&
|
|
4202
4485
|
!referencedStateBroken &&
|
|
4203
4486
|
existsSync(integrationTestPath) &&
|
|
4204
4487
|
opts.likelyParams &&
|
|
@@ -4377,7 +4660,7 @@ export async function externalVerification(
|
|
|
4377
4660
|
}
|
|
4378
4661
|
|
|
4379
4662
|
const loadBearing = identifyLoadBearingRequests(session);
|
|
4380
|
-
if (loadBearing.length > 0 && existsSync(parserPath)) {
|
|
4663
|
+
if (!safetyBlocked && loadBearing.length > 0 && existsSync(parserPath)) {
|
|
4381
4664
|
const firstReq = loadBearing[0];
|
|
4382
4665
|
if (firstReq?.response?.body) {
|
|
4383
4666
|
try {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { dirname, join as pathJoin } from 'node:path';
|
|
13
13
|
import { loadBackendsCacheStatus } from './backend-cache.ts';
|
|
14
14
|
import { runWorkflowWithLadder } from './backend-ladder.ts';
|
|
15
|
+
import { workflowHasIrreversibleEffect } from './effects.ts';
|
|
15
16
|
import type { CredentialStore } from './runtime.ts';
|
|
16
17
|
import { WorkflowSchema } from './types.ts';
|
|
17
18
|
|
|
@@ -55,10 +56,13 @@ function processIsAlive(pid: number): boolean {
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
async function acquireSiteLiveLock(
|
|
59
|
+
export async function acquireSiteLiveLock(
|
|
60
|
+
workflowPath: string,
|
|
61
|
+
deadlineMs = Date.now() + 10 * 60_000,
|
|
62
|
+
): Promise<() => void> {
|
|
59
63
|
const siteDir = dirname(dirname(workflowPath));
|
|
60
64
|
const lockPath = pathJoin(siteDir, '.imprint-live-verification.lock');
|
|
61
|
-
const deadline = Date.now() + 10 * 60_000;
|
|
65
|
+
const deadline = Math.min(deadlineMs, Date.now() + 10 * 60_000);
|
|
62
66
|
|
|
63
67
|
while (Date.now() < deadline) {
|
|
64
68
|
try {
|
|
@@ -122,6 +126,11 @@ export async function runCapturedIntegrationCase(opts: {
|
|
|
122
126
|
preferredOnlyBackend?: boolean;
|
|
123
127
|
}): Promise<LadderRun> {
|
|
124
128
|
const workflow = WorkflowSchema.parse(JSON.parse(readFileSync(opts.workflowPath, 'utf8')));
|
|
129
|
+
if (workflowHasIrreversibleEffect(workflow)) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Live integration is disabled for irreversible workflow ${JSON.stringify(workflow.toolName)}.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
125
134
|
const release = await acquireSiteLiveLock(opts.workflowPath);
|
|
126
135
|
const startedAt = Date.now();
|
|
127
136
|
let run: LadderRun;
|