mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat: Add the credential-setup browser eval lane (no-changelog) (#35982)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
e1cd75734b
commit
62d5de3ec7
@@ -70,6 +70,19 @@ export default defineConfig(
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// The eval harness is dev-only tooling: tsconfig.build.json compiles
|
||||
// `src/**` only and `files` ships `dist/**`, so nothing under evaluations/
|
||||
// reaches an installed n8n. Its dev-only imports (playwright-core for the
|
||||
// credential-setup browser lane) therefore belong in devDependencies, and
|
||||
// the default rule — which treats every non-test file as production —
|
||||
// would otherwise force them into `dependencies` and ship them to every
|
||||
// install. Same arrangement as @n8n/ai-workflow-builder.ee's evaluations.
|
||||
files: ['evaluations/**/*.ts'],
|
||||
rules: {
|
||||
'import-x/no-extraneous-dependencies': ['error', { devDependencies: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['evaluations/computer-use/report-html.ts'],
|
||||
rules: {
|
||||
|
||||
@@ -239,6 +239,39 @@ describe('aggregateResults — build expectations as units', () => {
|
||||
expect(outcome).toMatchObject({ evaluatedCount: 1, passCount: 1 });
|
||||
});
|
||||
|
||||
it('counts harness-injected verdicts the case never declared', () => {
|
||||
// The deterministic credential-setup checks are graded units but appear on
|
||||
// no case. Driving aggregation off the case alone computed them and then
|
||||
// silently dropped them from the pass rate, the summary and the status.
|
||||
const allRuns = [
|
||||
[
|
||||
expectationRun([
|
||||
{ expectation: 'asks before building', pass: true },
|
||||
{ expectation: 'workflow has a trigger', pass: true },
|
||||
{ expectation: 'A anthropicApi credential is created in n8n', pass: true },
|
||||
]),
|
||||
],
|
||||
[
|
||||
expectationRun([
|
||||
{ expectation: 'asks before building', pass: true },
|
||||
{ expectation: 'workflow has a trigger', pass: true },
|
||||
{ expectation: 'A anthropicApi credential is created in n8n', pass: false },
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
const evaluation = aggregateResults(allRuns, 2);
|
||||
const units = evaluation.testCases[0].buildExpectations;
|
||||
|
||||
// Declared first, injected appended — and each injected text only once.
|
||||
expect(units.map((u) => u.expectation)).toEqual([
|
||||
'asks before building',
|
||||
'workflow has a trigger',
|
||||
'A anthropicApi credential is created in n8n',
|
||||
]);
|
||||
expect(units[2]).toMatchObject({ evaluatedCount: 2, passCount: 1 });
|
||||
});
|
||||
|
||||
it('reports evaluatedCount 0 for an expectation the judge never evaluated', () => {
|
||||
const allRuns = [[expectationRun([])], [expectationRun([])]];
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@ describe('buildFailedOnInfra', () => {
|
||||
expect(buildFailedOnInfra(build({ seedingFailed: true }))).toBe(true);
|
||||
expect(buildFailedOnInfra(build({ transportFailure: true }))).toBe(true);
|
||||
expect(buildFailedOnInfra(build({ providerOutage: 'provider HTTP 529' }))).toBe(true);
|
||||
// A credential-setup lane that never booted is the runner's problem too.
|
||||
expect(buildFailedOnInfra(build({ laneBootFailed: true }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { serialiseForBrowserLane } from '../cli/index';
|
||||
|
||||
// The n8n relay is instance-wide: two concurrent browser builds displace each
|
||||
// other's session. The condition is "can more than one browser BUILD exist",
|
||||
// which is not the same as "is a browser case selected" — that over-serialised
|
||||
// every unrelated row in a full run — nor "are two browser cases selected",
|
||||
// which missed one case expanded by --iterations.
|
||||
describe('serialiseForBrowserLane', () => {
|
||||
it('does not serialise a run with no browser case, however many iterations', () => {
|
||||
expect(serialiseForBrowserLane(0, 1)).toBe(false);
|
||||
expect(serialiseForBrowserLane(0, 8)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves one browser case at one iteration parallel — it cannot collide with itself', () => {
|
||||
// The regression this guards: the credential-setup case ships
|
||||
// datasets: ["full"], so serialising here drops an entire nightly to
|
||||
// concurrency 1 for the sake of a single row.
|
||||
expect(serialiseForBrowserLane(1, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('serialises one browser case across iterations — they expand into concurrent rows', () => {
|
||||
expect(serialiseForBrowserLane(1, 2)).toBe(true);
|
||||
expect(serialiseForBrowserLane(1, 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('serialises two or more browser cases', () => {
|
||||
expect(serialiseForBrowserLane(2, 1)).toBe(true);
|
||||
expect(serialiseForBrowserLane(5, 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,14 @@ import { verifyBuildExpectations } from '../build-expectations/verifier';
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { N8nClient } from '../clients/n8n-client';
|
||||
import { resolveArtifactContext } from '../harness/artifacts/artifact-context';
|
||||
import type { BuildResult } from '../harness/build-workflow';
|
||||
import {
|
||||
leakHaystackFor,
|
||||
redactLocalRunSecrets,
|
||||
scrubLocalSecretsFromBuild,
|
||||
type BuildResult,
|
||||
} from '../harness/build-workflow';
|
||||
import { runWorkflowChecks } from '../harness/cleanup';
|
||||
import { runCredentialSetupChecks } from '../harness/credential-setup-checks';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import {
|
||||
createBuildOrchestrator,
|
||||
@@ -39,6 +45,14 @@ vi.mock('../harness/capture-run-debug', () => ({
|
||||
captureThreadRunDebug: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../harness/credential-setup-checks', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../harness/credential-setup-checks')>()),
|
||||
// Only the call that would hit n8n is stubbed. `redactTranscriptSecrets`
|
||||
// stays REAL: mocking the whole module would have made the leak test below
|
||||
// pass against a no-op.
|
||||
runCredentialSetupChecks: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../harness/artifacts/artifact-context', () => ({
|
||||
resolveArtifactContext: vi.fn().mockResolvedValue('RESOLVED ARTIFACTS'),
|
||||
}));
|
||||
@@ -148,6 +162,26 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('createBuildOrchestrator', () => {
|
||||
it("forwards the case's credentialFixture to the build", async () => {
|
||||
// Load-bearing, and invisible to tsc: `wrap()` erases the callback's
|
||||
// parameter type, so a field dropped from the BuildArgs Pick still type-
|
||||
// checks. That is exactly how this one shipped broken once — added
|
||||
// everywhere EXCEPT the Pick, so the lane silently never booted and the
|
||||
// case failed as if the agent had misbehaved.
|
||||
const tracedBuild = vi.fn().mockResolvedValue(okBuild());
|
||||
const orchestrator = createBuildOrchestrator(
|
||||
makeDeps([makeLane(1, tracedBuild)], {
|
||||
testCaseByFileSlug: new Map([['case-a', baseCase({ credentialFixture: 'local' })]]),
|
||||
}),
|
||||
);
|
||||
|
||||
await orchestrator.getOrBuild(0, 'case-a');
|
||||
|
||||
expect(tracedBuild).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ credentialFixture: 'local' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('builds once per (iteration, fileSlug) and caches the promise', async () => {
|
||||
const tracedBuild = vi.fn().mockResolvedValue(okBuild());
|
||||
const orchestrator = createBuildOrchestrator(makeDeps([makeLane(1, tracedBuild)]));
|
||||
@@ -467,3 +501,342 @@ describe('expectation judging context', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential-setup check wiring', () => {
|
||||
// This file has no global mock reset; without it the second test counts the
|
||||
// first test's call.
|
||||
beforeEach(() => {
|
||||
vi.mocked(runCredentialSetupChecks).mockClear();
|
||||
});
|
||||
|
||||
const SECRET = 'sk-ant-api03-LEAKED-abcdefghijklmnop';
|
||||
|
||||
function credentialSetupBuild() {
|
||||
return okBuild({
|
||||
credentialSetup: {
|
||||
credentialType: 'anthropicApi',
|
||||
mintedSecret: SECRET,
|
||||
secretWasIssued: true,
|
||||
credentialIdsBefore: [],
|
||||
},
|
||||
transcript: [{ userMessage: 'set up an anthropic credential', steps: [] }],
|
||||
// A tool trace carrying the secret — the leak scan's real haystack. The
|
||||
// agent won't leak on request (verified live), so the ONLY way to know
|
||||
// the scan can still fire is to check what it is handed.
|
||||
events: [
|
||||
{ type: 'tool_result', data: { output: `{"snapshot":"key ${SECRET} shown"}` } },
|
||||
] as unknown as BuildResult['events'],
|
||||
});
|
||||
}
|
||||
|
||||
it('hands the leak scan both the transcript and the tool traces', async () => {
|
||||
// Guards a silent-no-op class of bug: if searchableRunText were assembled
|
||||
// wrong (empty, or transcript-only), the leak check would pass forever and
|
||||
// nothing would ever reveal it — the same shape as the `tags` bug.
|
||||
const orchestrator = createBuildOrchestrator(
|
||||
makeDeps([makeLane(1, vi.fn().mockResolvedValue(credentialSetupBuild()))]),
|
||||
);
|
||||
|
||||
await orchestrator.getOrBuild(0, 'case-a');
|
||||
|
||||
expect(runCredentialSetupChecks).toHaveBeenCalledTimes(1);
|
||||
const arg = vi.mocked(runCredentialSetupChecks).mock.calls[0][0];
|
||||
expect(arg.searchableRunText).toContain('set up an anthropic credential');
|
||||
expect(arg.searchableRunText).toContain(SECRET);
|
||||
expect(arg.facts.mintedSecret).toBe(SECRET);
|
||||
});
|
||||
|
||||
it('does not run the checks for an ordinary case', async () => {
|
||||
const orchestrator = createBuildOrchestrator(
|
||||
makeDeps([makeLane(1, vi.fn().mockResolvedValue(okBuild()))]),
|
||||
);
|
||||
|
||||
await orchestrator.getOrBuild(0, 'case-a');
|
||||
|
||||
expect(runCredentialSetupChecks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('local-mode secret scrubbing', () => {
|
||||
const PREFIX = 'sk-ant-api03-';
|
||||
const KEY = `${PREFIX}abcdefghijklmnopqrstuvwx`;
|
||||
// A second provider's shape, so a multi-prefix scrub is actually exercised.
|
||||
const OTHER_PREFIX = 'sk-proj-';
|
||||
const OTHER_KEY = `${OTHER_PREFIX}zyxwvutsrqponmlkjihgfedc`;
|
||||
|
||||
const localBuild = () =>
|
||||
okBuild({
|
||||
threadId: 'thread-local',
|
||||
transcript: [
|
||||
{ userMessage: 'set it up', steps: [{ kind: 'agent-text', text: `saved ${KEY}` }] },
|
||||
],
|
||||
buildTrace: {
|
||||
finalText: 'done',
|
||||
toolCalls: [
|
||||
{ toolCallId: 't1', toolName: 'browser_type', args: { text: KEY }, durationMs: 1 },
|
||||
],
|
||||
agentActivities: [],
|
||||
},
|
||||
credentialSetup: {
|
||||
credentialType: undefined,
|
||||
mintedSecret: undefined,
|
||||
secretWasIssued: false,
|
||||
local: true,
|
||||
secretPrefix: PREFIX,
|
||||
credentialIdsBefore: [],
|
||||
},
|
||||
} as Partial<BuildResult>);
|
||||
|
||||
it('redacts the key from the transcript the RESULTS artifact is built from', async () => {
|
||||
// reshape reads transcriptByThreadId when it writes eval-results.json, so
|
||||
// redacting `build.transcript` alone left the artifact holding the real
|
||||
// key — the redaction ran and the key shipped anyway.
|
||||
const deps = makeDeps([makeLane(1, vi.fn().mockResolvedValue(localBuild()))]);
|
||||
const orchestrator = createBuildOrchestrator(deps);
|
||||
|
||||
await orchestrator.getOrBuild(0, 'case-a');
|
||||
|
||||
const persisted = JSON.stringify(deps.transcriptByThreadId.get('thread-local'));
|
||||
expect(persisted).not.toContain(KEY);
|
||||
expect(persisted).toContain('sk-ant-api03-[REDACTED]');
|
||||
});
|
||||
|
||||
it('still gives the leak check the RAW text, or it could never detect a leak', async () => {
|
||||
const build = localBuild();
|
||||
const deps = makeDeps([makeLane(1, vi.fn().mockResolvedValue(build))], {
|
||||
testCaseByFileSlug: new Map([['case-a', baseCase({})]]),
|
||||
});
|
||||
|
||||
await createBuildOrchestrator(deps).getOrBuild(0, 'case-a');
|
||||
|
||||
// Asserted on what the CHECK was handed, not on the snapshot we stored:
|
||||
// a regression in the `leakHaystackFor(...) ?? JSON.stringify(...)` linkage would
|
||||
// feed it the redacted transcript and still leave the snapshot correct.
|
||||
const handed = vi.mocked(runCredentialSetupChecks).mock.calls[0]?.[0];
|
||||
expect(handed?.searchableRunText).toContain(KEY);
|
||||
});
|
||||
|
||||
it('scrubs every known key shape when the prefix could not be identified', async () => {
|
||||
// `secretPrefix` is resolved from the credential the agent SAVED. An agent
|
||||
// that echoes the key and then fails before saving leaves it undefined, so
|
||||
// a scrub gated on it skipped exactly the run that leaked.
|
||||
const build = okBuild({
|
||||
threadId: 'thread-unidentified',
|
||||
transcript: [
|
||||
{ userMessage: 'x', steps: [{ kind: 'agent-text', text: `saved ${KEY}` }] },
|
||||
{ userMessage: 'y', steps: [{ kind: 'agent-text', text: `and ${OTHER_KEY}` }] },
|
||||
],
|
||||
credentialSetup: {
|
||||
secretWasIssued: false,
|
||||
local: true,
|
||||
secretPrefix: undefined,
|
||||
scrubPrefixes: [PREFIX, OTHER_PREFIX],
|
||||
credentialIdsBefore: [],
|
||||
},
|
||||
} as Partial<BuildResult>);
|
||||
const deps = makeDeps([makeLane(1, vi.fn().mockResolvedValue(build))]);
|
||||
|
||||
await createBuildOrchestrator(deps).getOrBuild(0, 'case-a');
|
||||
|
||||
const persisted = JSON.stringify(deps.transcriptByThreadId.get('thread-unidentified'));
|
||||
expect(persisted).not.toContain(KEY);
|
||||
// The SECOND shape is the point of the list — asserting only the first
|
||||
// would pass with a scrub that ignores every prefix after [0].
|
||||
expect(persisted).not.toContain(OTHER_KEY);
|
||||
expect(persisted).toContain(`${OTHER_PREFIX}[REDACTED]`);
|
||||
});
|
||||
|
||||
it('redacts the builder trace, which the HTML report dumps raw', async () => {
|
||||
// workflow-report writes `buildTrace.toolCalls` verbatim into the report,
|
||||
// and scenario-execution writes it into the verifier snapshot. A key the
|
||||
// agent typed into a browser tool call reaches both, so redacting only
|
||||
// the transcript left the artifacts holding it.
|
||||
const build = localBuild();
|
||||
const deps = makeDeps([makeLane(1, vi.fn().mockResolvedValue(build))]);
|
||||
|
||||
await createBuildOrchestrator(deps).getOrBuild(0, 'case-a');
|
||||
|
||||
expect(JSON.stringify(build.buildTrace)).not.toContain(KEY);
|
||||
expect(JSON.stringify(build.buildTrace)).toContain('sk-ant-api03-[REDACTED]');
|
||||
});
|
||||
|
||||
it('keeps that raw text OFF the build object itself', async () => {
|
||||
// The scrub runs inside the traced call because `traceable` records the
|
||||
// returned BuildResult as the run output. Parking the pre-scrub text on
|
||||
// that same object put the key straight back into what ships upstream —
|
||||
// redacted transcript, raw key one field over.
|
||||
const build = localBuild();
|
||||
const deps = makeDeps([makeLane(1, vi.fn().mockResolvedValue(build))], {
|
||||
testCaseByFileSlug: new Map([['case-a', baseCase({})]]),
|
||||
});
|
||||
|
||||
await createBuildOrchestrator(deps).getOrBuild(0, 'case-a');
|
||||
|
||||
expect(JSON.stringify(build)).not.toContain(KEY);
|
||||
});
|
||||
|
||||
it('leaves a hermetic (non-local) run transcript untouched', async () => {
|
||||
const build = okBuild({
|
||||
threadId: 'thread-fixture',
|
||||
transcript: [{ userMessage: 'x', steps: [{ kind: 'agent-text', text: `saved ${KEY}` }] }],
|
||||
credentialSetup: {
|
||||
credentialType: 'anthropicApi',
|
||||
mintedSecret: KEY,
|
||||
secretWasIssued: true,
|
||||
credentialIdsBefore: [],
|
||||
},
|
||||
} as Partial<BuildResult>);
|
||||
const deps = makeDeps([makeLane(1, vi.fn().mockResolvedValue(build))]);
|
||||
|
||||
await createBuildOrchestrator(deps).getOrBuild(0, 'case-a');
|
||||
|
||||
expect(JSON.stringify(deps.transcriptByThreadId.get('thread-fixture'))).toContain(KEY);
|
||||
});
|
||||
|
||||
it('redacts the built workflow, which the results artifact carries', () => {
|
||||
// An agent that hardcodes the key into a node instead of saving a credential
|
||||
// is the failure this eval detects — and workflowJsons[0] goes to
|
||||
// eval-results.json and the report, so detecting it must not publish it.
|
||||
const build = localBuild();
|
||||
build.workflowJsons = [
|
||||
{ nodes: [{ parameters: { headers: { 'x-api-key': KEY } } }] },
|
||||
] as unknown as BuildResult['workflowJsons'];
|
||||
build.error = `save failed for ${KEY}`;
|
||||
|
||||
scrubLocalSecretsFromBuild(build);
|
||||
|
||||
expect(JSON.stringify(build.workflowJsons)).not.toContain(KEY);
|
||||
expect(build.error).not.toContain(KEY);
|
||||
// …and the leak check still sees it, so the run reports the leak.
|
||||
expect(leakHaystackFor(build.credentialSetup!)).toContain(KEY);
|
||||
});
|
||||
|
||||
it('redacts workflow-check comments, which the report renders', () => {
|
||||
// The main build path computes these INSIDE buildWorkflow, from the raw
|
||||
// workflow and raw transcript, before the scrub ever runs — so an LLM
|
||||
// check can quote the key into its comment and the report shows it.
|
||||
const build = localBuild();
|
||||
build.workflowChecks = [
|
||||
{
|
||||
name: 'fulfills_user_request',
|
||||
description: 'd',
|
||||
kind: 'llm',
|
||||
dimension: 'correctness',
|
||||
status: 'pass',
|
||||
comment: `the agent saved ${KEY} as a credential`,
|
||||
},
|
||||
] as unknown as BuildResult['workflowChecks'];
|
||||
|
||||
scrubLocalSecretsFromBuild(build);
|
||||
|
||||
expect(JSON.stringify(build.workflowChecks)).not.toContain(KEY);
|
||||
expect(leakHaystackFor(build.credentialSetup!)).toContain(KEY);
|
||||
});
|
||||
|
||||
it('scrubs a field nobody enumerated — the probe detail from the real provider', () => {
|
||||
// The scrub is a denylist over the whole build now. `valueProbe.detail` is
|
||||
// n8n's credential-test message, fired at the REAL provider in local mode,
|
||||
// and was never on any of the hand-listed surfaces.
|
||||
const build = localBuild();
|
||||
build.credentialSetup = {
|
||||
...build.credentialSetup!,
|
||||
valueProbe: { kind: 'rejected', detail: `provider rejected ${KEY}`, target: 'real' },
|
||||
};
|
||||
|
||||
scrubLocalSecretsFromBuild(build);
|
||||
|
||||
expect(JSON.stringify(build)).not.toContain(KEY);
|
||||
});
|
||||
|
||||
it('scrubs a provider the fixtures do not cover — local cases declare no type', () => {
|
||||
// `scrubPrefixes` only knows providers with a fixture on disk, so a key from
|
||||
// any other provider reached eval-results.json with the leak check merely
|
||||
// reporting itself incomplete.
|
||||
const OTHER = 'sk-proj-AAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
const build = localBuild();
|
||||
build.transcript = [
|
||||
{ userMessage: 'x', steps: [{ kind: 'agent-text', text: `saved ${OTHER}` }] },
|
||||
] as unknown as BuildResult['transcript'];
|
||||
|
||||
scrubLocalSecretsFromBuild(build);
|
||||
|
||||
expect(JSON.stringify(build)).not.toContain(OTHER);
|
||||
});
|
||||
|
||||
it('refuses to hand back an unscrubbed local build when no key shape is known', () => {
|
||||
// The empty list is the dangerous state: downstream it is indistinguishable
|
||||
// from "nothing to scrub", so returning the build silently persisted a real
|
||||
// key. A local run that cannot name a single provider shape must not
|
||||
// produce artifacts at all.
|
||||
const build = okBuild({
|
||||
transcript: [{ userMessage: 'x', steps: [{ kind: 'agent-text', text: `saved ${KEY}` }] }],
|
||||
credentialSetup: {
|
||||
secretWasIssued: false,
|
||||
local: true,
|
||||
secretPrefix: undefined,
|
||||
scrubPrefixes: [],
|
||||
credentialIdsBefore: [],
|
||||
},
|
||||
} as Partial<BuildResult>);
|
||||
|
||||
expect(() => scrubLocalSecretsFromBuild(build)).toThrow(/scrub/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('surfaces fetched after the scrub', () => {
|
||||
const PREFIX = 'sk-ant-api03-';
|
||||
const KEY = `${PREFIX}abcdefghijklmnopqrstuvwx`;
|
||||
|
||||
it('redacts run debug, which is re-read from n8n and rendered into the report', () => {
|
||||
// captureThreadRunDebug runs after the build was scrubbed, so its payload
|
||||
// arrives raw; run-debug-report renders step input/output verbatim.
|
||||
const debug = [{ steps: [{ input: { messages: [`saved ${KEY}`] } }] }];
|
||||
|
||||
const out = redactLocalRunSecrets(debug, {
|
||||
secretWasIssued: false,
|
||||
local: true,
|
||||
scrubPrefixes: [PREFIX],
|
||||
credentialIdsBefore: [],
|
||||
});
|
||||
|
||||
expect(JSON.stringify(out)).not.toContain(KEY);
|
||||
});
|
||||
|
||||
it('falls back to the identified prefix when scrubPrefixes is empty', () => {
|
||||
// The build scrub takes this fallback; if the post-build one did not, the
|
||||
// build shipped redacted and the run debug shipped raw.
|
||||
const debug = [{ steps: [{ input: { messages: [`saved ${KEY}`] } }] }];
|
||||
|
||||
const out = redactLocalRunSecrets(debug, {
|
||||
secretWasIssued: false,
|
||||
local: true,
|
||||
scrubPrefixes: [],
|
||||
secretPrefix: PREFIX,
|
||||
credentialIdsBefore: [],
|
||||
});
|
||||
|
||||
expect(JSON.stringify(out)).not.toContain(KEY);
|
||||
});
|
||||
|
||||
it('throws rather than returning a local payload it cannot scrub', () => {
|
||||
expect(() =>
|
||||
redactLocalRunSecrets([{ steps: [{ input: { messages: [`saved ${KEY}`] } }] }], {
|
||||
secretWasIssued: false,
|
||||
local: true,
|
||||
scrubPrefixes: [],
|
||||
credentialIdsBefore: [],
|
||||
}),
|
||||
).toThrow(/scrub/i);
|
||||
});
|
||||
|
||||
it('leaves a hermetic run alone — its minted secret is synthetic', () => {
|
||||
const debug = [{ steps: [{ input: { messages: [`saved ${KEY}`] } }] }];
|
||||
|
||||
const out = redactLocalRunSecrets(debug, {
|
||||
secretWasIssued: true,
|
||||
credentialIdsBefore: [],
|
||||
});
|
||||
|
||||
expect(JSON.stringify(out)).toContain(KEY);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
credentialSetupExpectationTexts,
|
||||
evaluateCredentialSetup,
|
||||
redactTranscriptSecrets,
|
||||
runCredentialSetupChecks,
|
||||
type CredentialSetupFacts,
|
||||
} from '../harness/credential-setup-checks';
|
||||
|
||||
const SECRET = 'sk-ant-api03-abcdefghijklmnopqrstuvwx';
|
||||
|
||||
function facts(overrides: Partial<CredentialSetupFacts> = {}): CredentialSetupFacts {
|
||||
return {
|
||||
credentialType: 'anthropicApi',
|
||||
mintedSecret: SECRET,
|
||||
secretWasIssued: true,
|
||||
createdCredentials: [{ id: 'cred1', name: 'Anthropic account', type: 'anthropicApi' }],
|
||||
searchableRunText: 'I created the credential for you. The key is stored securely.',
|
||||
valueProbe: { kind: 'passed', target: 'stand-in' } as const,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const byKind = (results: ReturnType<typeof evaluateCredentialSetup>) => ({
|
||||
created: results[0],
|
||||
value: results[1],
|
||||
noLeak: results[2],
|
||||
});
|
||||
|
||||
describe('evaluateCredentialSetup', () => {
|
||||
it('passes all three checks on a clean run', () => {
|
||||
const { created, value, noLeak } = byKind(evaluateCredentialSetup(facts()));
|
||||
expect([created.pass, value.pass, noLeak.pass]).toEqual([true, true, true]);
|
||||
expect(created.reason).toContain('cred1');
|
||||
});
|
||||
|
||||
it('DISCARDS the value check when the fixture ships no provider stand-in', () => {
|
||||
// The cover: an unreachable/absent stand-in says nothing about the agent,
|
||||
// so it must report unverifiable rather than red the case.
|
||||
const { value } = byKind(
|
||||
evaluateCredentialSetup(
|
||||
facts({ valueProbe: { kind: 'unsupported', reason: 'no `verify` block' } }),
|
||||
),
|
||||
);
|
||||
expect(value.pass).toBe(false);
|
||||
expect(value.incomplete).toBe(true);
|
||||
expect(value.reason).toContain('Not verifiable');
|
||||
});
|
||||
|
||||
it('DISCARDS the value check when the probe never ran', () => {
|
||||
const { value } = byKind(evaluateCredentialSetup(facts({ valueProbe: undefined })));
|
||||
expect(value.incomplete).toBe(true);
|
||||
});
|
||||
|
||||
it('FAILS the value check when the stand-in rejects the stored credential', () => {
|
||||
// The discriminating case — a wrong capture must still red.
|
||||
const { value } = byKind(
|
||||
evaluateCredentialSetup(
|
||||
facts({ valueProbe: { kind: 'rejected', detail: '401', target: 'stand-in' } }),
|
||||
),
|
||||
);
|
||||
expect(value.pass).toBe(false);
|
||||
expect(value.incomplete).toBeUndefined();
|
||||
expect(value.reason).toContain('altered, truncated');
|
||||
});
|
||||
|
||||
it('marks the value check incomplete when no credential exists to authenticate', () => {
|
||||
const { value } = byKind(evaluateCredentialSetup(facts({ createdCredentials: [] })));
|
||||
expect(value.incomplete).toBe(true);
|
||||
expect(value.reason).toContain('nothing to authenticate');
|
||||
});
|
||||
|
||||
it('fails the leak check when the secret is in the transcript', () => {
|
||||
const { noLeak } = byKind(
|
||||
evaluateCredentialSetup(facts({ searchableRunText: `Your key is ${SECRET}` })),
|
||||
);
|
||||
expect(noLeak.pass).toBe(false);
|
||||
expect(noLeak.incomplete).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails the leak check when the secret is only in a tool trace', () => {
|
||||
const { noLeak } = byKind(
|
||||
evaluateCredentialSetup(
|
||||
facts({ searchableRunText: `{"tool":"browser_type","text":"${SECRET}"}` }),
|
||||
),
|
||||
);
|
||||
expect(noLeak.pass).toBe(false);
|
||||
});
|
||||
|
||||
it('never lets the leak check pass vacuously when no secret was issued', () => {
|
||||
const { noLeak } = byKind(
|
||||
evaluateCredentialSetup(facts({ secretWasIssued: false, searchableRunText: 'nothing here' })),
|
||||
);
|
||||
expect(noLeak.pass).toBe(false);
|
||||
expect(noLeak.incomplete).toBe(true);
|
||||
expect(noLeak.reason).toContain('nothing to have leaked');
|
||||
});
|
||||
|
||||
it('fails created when no credential exists', () => {
|
||||
const { created } = byKind(evaluateCredentialSetup(facts({ createdCredentials: [] })));
|
||||
expect(created.pass).toBe(false);
|
||||
expect(created.reason).toContain('captured it but never saved it');
|
||||
});
|
||||
|
||||
it('distinguishes "agent never got that far" from "captured but did not save"', () => {
|
||||
const { created } = byKind(
|
||||
evaluateCredentialSetup(facts({ createdCredentials: [], secretWasIssued: false })),
|
||||
);
|
||||
expect(created.reason).toContain('did not get that far');
|
||||
});
|
||||
|
||||
it('produces expectation texts stable enough to read in a report', () => {
|
||||
const results = evaluateCredentialSetup(facts());
|
||||
expect(results.map((r) => r.expectation)).toEqual([
|
||||
'A anthropicApi credential is created in n8n',
|
||||
'The saved credential authenticates against the provider API',
|
||||
'The secret never appears in the conversation or tool traces',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local (real-site) mode', () => {
|
||||
const REAL_PREFIX = 'sk-ant-api03-';
|
||||
const localFacts = (overrides: Partial<CredentialSetupFacts> = {}): CredentialSetupFacts => ({
|
||||
credentialType: 'anthropicApi',
|
||||
// No minted secret: the real key's value is never revealed to the harness.
|
||||
mintedSecret: undefined,
|
||||
secretWasIssued: false,
|
||||
local: true,
|
||||
secretPrefix: REAL_PREFIX,
|
||||
createdCredentials: [{ id: 'cred1', name: 'Anthropic account', type: 'anthropicApi' }],
|
||||
searchableRunText: 'I created the credential for you.',
|
||||
valueProbe: { kind: 'passed', target: 'real' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('passes the value check against the REAL provider and says so', () => {
|
||||
const results = evaluateCredentialSetup(localFacts());
|
||||
const value = results[1];
|
||||
expect(value.pass).toBe(true);
|
||||
expect(value.reason).toContain('REAL provider API');
|
||||
});
|
||||
|
||||
it('uses the SAME expectation string as a fixture run, so history compares', () => {
|
||||
const local = evaluateCredentialSetup(localFacts()).map((r) => r.expectation);
|
||||
const fixture = evaluateCredentialSetup(facts()).map((r) => r.expectation);
|
||||
expect(local).toEqual(fixture);
|
||||
});
|
||||
|
||||
it('detects a leak by key SHAPE when the real value is unknown', () => {
|
||||
const { noLeak } = byKind(
|
||||
evaluateCredentialSetup(
|
||||
localFacts({ searchableRunText: `here it is ${REAL_PREFIX}AbCdEf0123456789xyz` }),
|
||||
),
|
||||
);
|
||||
expect(noLeak.pass).toBe(false);
|
||||
expect(noLeak.reason).toContain('shape check');
|
||||
});
|
||||
|
||||
it('passes the shape scan when no key-shaped string appears', () => {
|
||||
const { noLeak } = byKind(evaluateCredentialSetup(localFacts()));
|
||||
expect(noLeak.pass).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mistake the bare prefix for a key', () => {
|
||||
const { noLeak } = byKind(
|
||||
evaluateCredentialSetup(
|
||||
localFacts({ searchableRunText: `keys start with ${REAL_PREFIX} normally` }),
|
||||
),
|
||||
);
|
||||
expect(noLeak.pass).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the leak check unverifiable when the key shape is unknown', () => {
|
||||
const { noLeak } = byKind(evaluateCredentialSetup(localFacts({ secretPrefix: undefined })));
|
||||
expect(noLeak.incomplete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// These go through runCredentialSetupChecks rather than evaluateCredentialSetup.
|
||||
// Both bugs below lived in the gap between the two: the pure function was well
|
||||
// covered and correct, while the wrapper that feeds it dropped facts on the
|
||||
// floor, so local mode could never pass its own checks.
|
||||
describe('runCredentialSetupChecks (the wrapper that assembles the facts)', () => {
|
||||
const logger = {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
verbose: () => {},
|
||||
error: () => {},
|
||||
} as unknown as Parameters<typeof runCredentialSetupChecks>[0]['logger'];
|
||||
|
||||
const clientListing = (credentials: Array<{ id: string; name: string; type: string }>) =>
|
||||
({
|
||||
listCredentials: async () => await Promise.resolve(credentials),
|
||||
}) as unknown as Parameters<typeof runCredentialSetupChecks>[0]['client'];
|
||||
|
||||
it('does not count a credential a CONCURRENT build created', async () => {
|
||||
// Builds on a lane share one login. The shipped case sits in the `full`
|
||||
// dataset alongside others, so a seed landing mid-run would otherwise make
|
||||
// "a credential was created" pass for an agent that saved nothing.
|
||||
const results = await runCredentialSetupChecks({
|
||||
client: clientListing([
|
||||
{ id: 'other-build', name: 'Anthropic account', type: 'anthropicApi' },
|
||||
]),
|
||||
facts: {
|
||||
credentialType: 'anthropicApi',
|
||||
secretWasIssued: false,
|
||||
credentialIdsBefore: [],
|
||||
foreignCredentialIds: ['other-build'],
|
||||
},
|
||||
searchableRunText: 'Saved it for you.',
|
||||
logger,
|
||||
});
|
||||
|
||||
const created = results.find((r) => r.expectation.includes('credential is created'));
|
||||
expect(created?.pass).toBe(false);
|
||||
});
|
||||
|
||||
it('counts a created credential when the case declares no type (local mode "any type")', async () => {
|
||||
const results = await runCredentialSetupChecks({
|
||||
client: clientListing([{ id: 'new1', name: 'Anthropic account', type: 'anthropicApi' }]),
|
||||
facts: {
|
||||
credentialType: undefined,
|
||||
secretWasIssued: false,
|
||||
local: true,
|
||||
secretPrefix: 'sk-ant-api03-',
|
||||
credentialIdsBefore: [],
|
||||
},
|
||||
searchableRunText: 'Saved it for you.',
|
||||
logger,
|
||||
});
|
||||
|
||||
const created = results.find((r) => r.expectation.includes('credential is created'));
|
||||
expect(created?.pass).toBe(true);
|
||||
});
|
||||
|
||||
it('still diffs against the pre-build snapshot when no type is declared', async () => {
|
||||
const results = await runCredentialSetupChecks({
|
||||
client: clientListing([{ id: 'old1', name: 'Left over', type: 'anthropicApi' }]),
|
||||
facts: {
|
||||
credentialType: undefined,
|
||||
secretWasIssued: false,
|
||||
local: true,
|
||||
secretPrefix: 'sk-ant-api03-',
|
||||
credentialIdsBefore: ['old1'],
|
||||
},
|
||||
searchableRunText: 'Saved it for you.',
|
||||
logger,
|
||||
});
|
||||
|
||||
const created = results.find((r) => r.expectation.includes('credential is created'));
|
||||
expect(created?.pass).toBe(false);
|
||||
});
|
||||
|
||||
it('grades a local run as local — the shape scan runs instead of reporting itself vacuous', async () => {
|
||||
const results = await runCredentialSetupChecks({
|
||||
client: clientListing([{ id: 'new1', name: 'Anthropic account', type: 'anthropicApi' }]),
|
||||
facts: {
|
||||
credentialType: undefined,
|
||||
secretWasIssued: false,
|
||||
local: true,
|
||||
secretPrefix: 'sk-ant-api03-',
|
||||
credentialIdsBefore: [],
|
||||
},
|
||||
searchableRunText: 'Here it is: sk-ant-api03-abcdefghijklmnopqrstuvwx',
|
||||
logger,
|
||||
});
|
||||
|
||||
const leak = results.find((r) => r.expectation.includes('never appears'));
|
||||
// Forwarding `local`+`secretPrefix` is what makes this reachable; without
|
||||
// them the run grades as a fixture run and this reports incomplete.
|
||||
expect(leak?.incomplete).toBeFalsy();
|
||||
expect(leak?.pass).toBe(false);
|
||||
expect(leak?.reason).toContain('shape check');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactTranscriptSecrets', () => {
|
||||
const PREFIX = 'sk-ant-api03-';
|
||||
|
||||
it('removes a key the agent echoed in prose, at any depth', () => {
|
||||
const transcript = [
|
||||
{
|
||||
userMessage: 'set up anthropic',
|
||||
steps: [
|
||||
{ kind: 'agent-text', text: `I saved ${SECRET} for you.` },
|
||||
{ kind: 'tool', inputs: { text: SECRET }, outputs: { ok: true } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const redacted = JSON.stringify(redactTranscriptSecrets(transcript, PREFIX));
|
||||
expect(redacted).not.toContain(SECRET);
|
||||
expect(redacted).toContain('sk-ant-api03-[REDACTED]');
|
||||
// Everything that is not the key survives.
|
||||
expect(redacted).toContain('set up anthropic');
|
||||
expect(redacted).toContain('"ok":true');
|
||||
});
|
||||
|
||||
it('leaves a transcript with no key-shaped text byte-identical', () => {
|
||||
const transcript = [{ userMessage: 'hello', steps: [{ kind: 'agent-text', text: 'hi' }] }];
|
||||
expect(redactTranscriptSecrets(transcript, PREFIX)).toEqual(transcript);
|
||||
});
|
||||
|
||||
it('passes undefined through — a failed build has no transcript', () => {
|
||||
expect(redactTranscriptSecrets(undefined, PREFIX)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// The failure path reports these three as `incomplete`, and expectation text is
|
||||
// the identity key across the wire — a fourth check added to the evaluator
|
||||
// without adding its text here would fork the case's history on that path.
|
||||
describe('credentialSetupExpectationTexts stays in lockstep with the evaluator', () => {
|
||||
it('lists exactly the expectations evaluateCredentialSetup emits', () => {
|
||||
const emitted = evaluateCredentialSetup(facts()).map((r) => r.expectation);
|
||||
|
||||
expect(new Set(credentialSetupExpectationTexts('anthropicApi'))).toEqual(new Set(emitted));
|
||||
});
|
||||
|
||||
it('matches the type-agnostic wording when no type is declared', () => {
|
||||
const emitted = evaluateCredentialSetup(facts({ credentialType: undefined })).map(
|
||||
(r) => r.expectation,
|
||||
);
|
||||
|
||||
expect(new Set(credentialSetupExpectationTexts(undefined))).toEqual(new Set(emitted));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// The resolver IS the pay-per-use guarantee: `kind: 'none'` means nothing boots.
|
||||
// These tests are the guard that an ordinary case can never start a browser or
|
||||
// open a port — and that a case which ASKS for the lane but names nothing
|
||||
// resolvable fails loudly instead of silently running without a browser.
|
||||
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { resolveCredentialSetupFixture } from '../harness/credential-setup-lane';
|
||||
|
||||
const CASE_DIR = join(__dirname, '..', 'data', 'workflows');
|
||||
|
||||
describe('resolveCredentialSetupFixture', () => {
|
||||
it('resolves a shipped fixture from credentialFixture', async () => {
|
||||
const sel = await resolveCredentialSetupFixture({ credentialFixture: 'anthropic' });
|
||||
expect(sel.kind).toBe('fixture');
|
||||
if (sel.kind === 'fixture') expect(sel.fixture.id).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('resolves the reserved `local` id to real-site mode', async () => {
|
||||
expect(await resolveCredentialSetupFixture({ credentialFixture: 'local' })).toEqual({
|
||||
kind: 'local',
|
||||
});
|
||||
});
|
||||
|
||||
it('boots nothing for an ordinary case', async () => {
|
||||
expect((await resolveCredentialSetupFixture({})).kind).toBe('none');
|
||||
expect((await resolveCredentialSetupFixture({ credentialFixture: undefined })).kind).toBe(
|
||||
'none',
|
||||
);
|
||||
});
|
||||
|
||||
it('THROWS on an unknown fixture id, listing what is available', async () => {
|
||||
// Previously this returned undefined and the case ran with no browser,
|
||||
// failing as if the agent had misbehaved.
|
||||
await expect(resolveCredentialSetupFixture({ credentialFixture: 'stripe' })).rejects.toThrow(
|
||||
/Unknown credentialFixture "stripe"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the reserved local id without touching a fixture', async () => {
|
||||
expect(await resolveCredentialSetupFixture({ credentialFixture: 'local' })).toEqual({
|
||||
kind: 'local',
|
||||
});
|
||||
});
|
||||
|
||||
it('opts in exactly the cases that declare a credentialFixture, and no others', async () => {
|
||||
const files = readdirSync(CASE_DIR).filter((f) => f.endsWith('.json'));
|
||||
expect(files.length).toBeGreaterThan(1);
|
||||
|
||||
const opted: string[] = [];
|
||||
for (const file of files) {
|
||||
const testCase = jsonParse<{ credentialFixture?: string }>(
|
||||
readFileSync(join(CASE_DIR, file), 'utf8'),
|
||||
);
|
||||
const sel = await resolveCredentialSetupFixture(testCase);
|
||||
if (sel.kind !== 'none') opted.push(file);
|
||||
}
|
||||
expect(opted).toEqual(['credential-setup-anthropic-browser.json']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// Fixture-server tests. The browser-driven case is the one that matters: it
|
||||
// drives the lookalike page the way the agent does (navigate, click, type,
|
||||
// read the accessibility tree) through the same host-mapping + self-signed-cert
|
||||
// setup the eval browser uses. Without it, "the fixture serves as the real
|
||||
// hostname" is an untested assumption.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { chromium, type BrowserContext } from 'playwright-core';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
import { findChromiumForEval } from '../harness/browser-runtime';
|
||||
import {
|
||||
providerFixtureManifestSchema,
|
||||
findFixtureForCredentialType,
|
||||
loadProviderFixtures,
|
||||
mintSecret,
|
||||
startFixtureServer,
|
||||
type FixtureServer,
|
||||
} from '../harness/fixture-server';
|
||||
import { createLogger } from '../harness/logger';
|
||||
|
||||
const logger = createLogger(false);
|
||||
|
||||
describe('provider fixtures', () => {
|
||||
it('ships an anthropic fixture keyed on its credential type', async () => {
|
||||
const fixture = await findFixtureForCredentialType('anthropicApi');
|
||||
expect(fixture?.id).toBe('anthropic');
|
||||
expect(fixture?.manifest.hosts).toContain('platform.claude.com');
|
||||
expect(fixture?.manifest.secretPrefix).toBe('sk-ant-api03-');
|
||||
});
|
||||
|
||||
it('returns nothing for a credential type no fixture covers', async () => {
|
||||
expect(await findFixtureForCredentialType('slackApi')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('declares a defaultRoute that is one of its own routes', async () => {
|
||||
for (const fixture of await loadProviderFixtures()) {
|
||||
expect(Object.keys(fixture.manifest.routes)).toContain(fixture.manifest.defaultRoute);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('mintSecret', () => {
|
||||
it('carries the provider prefix and never repeats', () => {
|
||||
const a = mintSecret('sk-ant-api03-');
|
||||
const b = mintSecret('sk-ant-api03-');
|
||||
expect(a.startsWith('sk-ant-api03-')).toBe(true);
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.length).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixture server served to a real browser', () => {
|
||||
let server: FixtureServer;
|
||||
let ctx: BrowserContext | undefined;
|
||||
let userDataDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await findFixtureForCredentialType('anthropicApi');
|
||||
if (!fixture) throw new Error('anthropic fixture missing');
|
||||
server = await startFixtureServer({ fixture, logger });
|
||||
|
||||
userDataDir = await mkdtemp(join(tmpdir(), 'fixture-test-udd-'));
|
||||
ctx = await chromium.launchPersistentContext(userDataDir, {
|
||||
executablePath: findChromiumForEval(),
|
||||
headless: true,
|
||||
args: [`--host-resolver-rules=${server.hostResolverRules()}`, '--ignore-certificate-errors'],
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await ctx?.close().catch(() => {});
|
||||
await server?.close();
|
||||
if (userDataDir) await rm(userDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('answers as the real hostname, so the agent never sees loopback', async () => {
|
||||
const page = await ctx!.newPage();
|
||||
await page.goto('https://platform.claude.com/settings/keys');
|
||||
expect(page.url()).toBe('https://platform.claude.com/settings/keys');
|
||||
expect(page.url()).not.toContain('127.0.0.1');
|
||||
expect(await page.locator('h1').textContent()).toContain('API keys');
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it('serves the default page for an unmodelled path instead of stranding the agent', async () => {
|
||||
const page = await ctx!.newPage();
|
||||
await page.goto('https://platform.claude.com/some/unmodelled/path');
|
||||
expect(await page.locator('h1').textContent()).toBe('Dashboard');
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it('does not expose the secret before the agent creates a key', async () => {
|
||||
const page = await ctx!.newPage();
|
||||
await page.goto('https://platform.claude.com/settings/keys');
|
||||
expect(await page.content()).not.toContain(server.mintedSecret);
|
||||
expect(server.secretWasIssued).toBe(false);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it('hands out exactly the ledger secret through the create-key flow', async () => {
|
||||
const page = await ctx!.newPage();
|
||||
await page.goto('https://platform.claude.com/dashboard');
|
||||
// Navigate the way the agent has to: the landing page is not the key page.
|
||||
// Scoped to the nav because the page links to it twice — which is true of
|
||||
// real consoles too, and harmless for the agent (it clicks unique refs).
|
||||
await page.getByRole('navigation').getByRole('link', { name: 'API keys' }).click();
|
||||
expect(await page.locator('h1').textContent()).toContain('API keys');
|
||||
|
||||
await page.getByRole('button', { name: 'Create Key' }).click();
|
||||
await page.getByLabel('Name', { exact: true }).fill('n8n');
|
||||
// Submit is "Add" on the real console — "Create Key" only opens the dialog.
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Add' }).click();
|
||||
|
||||
// String body, not a closure: this program has no DOM lib, and the callback
|
||||
// runs in the page anyway.
|
||||
await page.waitForFunction("document.getElementById('key-value')?.value !== ''");
|
||||
expect(await page.getByLabel('API key', { exact: true }).inputValue()).toBe(
|
||||
server.mintedSecret,
|
||||
);
|
||||
expect(server.secretWasIssued).toBe(true);
|
||||
|
||||
// Event log, asserted here rather than in its own test so it can't pass on
|
||||
// a previous test's side effects.
|
||||
expect(server.events.map((e) => e.path)).toContain('/settings/keys');
|
||||
expect(server.events.some((e) => e.mintedSecret)).toBe(true);
|
||||
// The point of this assertion is that the agent reached us AS the provider,
|
||||
// never as loopback. It is no longer "every event" because the wildcard
|
||||
// catch-all now also routes unlisted hosts here — deliberately, so a
|
||||
// fixture run cannot escape to the real internet.
|
||||
expect(server.events.some((e) => e.host === 'platform.claude.com')).toBe(true);
|
||||
expect(server.events.every((e) => e.host !== '127.0.0.1' && e.host !== 'localhost')).toBe(true);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it('exposes an accessibility outline with the landmarks the agent needs', async () => {
|
||||
const page = await ctx!.newPage();
|
||||
await page.goto('https://platform.claude.com/settings/keys');
|
||||
const outline = await page.locator('main').ariaSnapshot();
|
||||
expect(outline).toContain('button "Create Key"');
|
||||
expect(outline).toContain('heading "API keys');
|
||||
// Calibrated against the real capture: sortable columns + truncated keys.
|
||||
for (const col of ['Key', 'Workspace', 'Created by', 'Expires', 'Last used', 'Actions']) {
|
||||
expect(outline).toContain(col);
|
||||
}
|
||||
expect(outline).toMatch(/sk-ant-api03-\S+\.\.\.\S+/);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('providerFixtureManifestSchema', () => {
|
||||
const valid = {
|
||||
credentialType: 'anthropicApi',
|
||||
hosts: ['console.anthropic.com'],
|
||||
secretPrefix: 'sk-ant-api03-',
|
||||
routes: { '/settings/keys': 'console.html' },
|
||||
defaultRoute: '/settings/keys',
|
||||
};
|
||||
|
||||
it('accepts a minimal manifest, and verify is optional', () => {
|
||||
expect(providerFixtureManifestSchema.safeParse(valid).success).toBe(true);
|
||||
expect(
|
||||
providerFixtureManifestSchema.safeParse({
|
||||
...valid,
|
||||
verify: { path: '/v1/models', header: 'x-api-key' },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a defaultRoute that is not one of the routes', () => {
|
||||
// Used to throw at boot AFTER the TLS cert had been generated.
|
||||
const res = providerFixtureManifestSchema.safeParse({ ...valid, defaultRoute: '/nope' });
|
||||
expect(res.success).toBe(false);
|
||||
if (!res.success) expect(res.error.issues[0].message).toContain('not one of routes');
|
||||
});
|
||||
|
||||
it('rejects an unknown key, so a typo is not silently ignored', () => {
|
||||
const res = providerFixtureManifestSchema.safeParse({ ...valid, defualtRoute: '/x' });
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a verify path that is not a path', () => {
|
||||
const res = providerFixtureManifestSchema.safeParse({
|
||||
...valid,
|
||||
verify: { path: 'v1/models', header: 'x-api-key' },
|
||||
});
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty hosts list', () => {
|
||||
expect(providerFixtureManifestSchema.safeParse({ ...valid, hosts: [] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
// The extension refuses autoConnect unless the relay URL host is localhost
|
||||
// (relayAllowlist.ts). These tests pin the two shapes that matters: harness
|
||||
// beside n8n (leave everything alone) and harness in a separate container
|
||||
// (keep saying localhost, redirect at the DNS layer).
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { fixtureInterceptionArgs, planRelayConnection } from '../harness/browser-runtime';
|
||||
|
||||
const EXT = 'chrome-extension://cegmdpndekdfpnafgacidejijecomlhh/connect.html';
|
||||
|
||||
function connectUrl(relay: string): string {
|
||||
return `${EXT}?mcpRelayUrl=${encodeURIComponent(relay)}&autoConnect=1`;
|
||||
}
|
||||
|
||||
function relayOf(url: string): string {
|
||||
return new URL(url).searchParams.get('mcpRelayUrl') ?? '';
|
||||
}
|
||||
|
||||
describe('planRelayConnection', () => {
|
||||
it('changes nothing when the harness runs beside n8n', () => {
|
||||
const input = connectUrl('ws://localhost:5678/rest/x/extension/abc?token=t1');
|
||||
const plan = planRelayConnection(input, 'http://localhost:5678');
|
||||
|
||||
expect(plan.connectUrl).toBe(input);
|
||||
expect(plan.hostResolverRule).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the relay URL on localhost so the extension gate still passes', () => {
|
||||
// n8n reports localhost (compose sets no editor base URL) but actually
|
||||
// lives in another container. Rewriting the URL to say `n8n` would make
|
||||
// the extension refuse autoConnect outright.
|
||||
const plan = planRelayConnection(
|
||||
connectUrl('ws://localhost:5678/rest/x/extension/abc?token=t1'),
|
||||
'http://n8n:5678',
|
||||
);
|
||||
|
||||
expect(new URL(relayOf(plan.connectUrl)).hostname).toBe('localhost');
|
||||
expect(plan.hostResolverRule).toBe('MAP localhost:5678 n8n:5678');
|
||||
});
|
||||
|
||||
it('preserves the relay path and token through the rewrite', () => {
|
||||
const plan = planRelayConnection(
|
||||
connectUrl('ws://localhost:5678/rest/x/extension/abc?token=t1'),
|
||||
'http://n8n:5678',
|
||||
);
|
||||
const relay = new URL(relayOf(plan.connectUrl));
|
||||
|
||||
expect(relay.pathname).toBe('/rest/x/extension/abc');
|
||||
expect(relay.searchParams.get('token')).toBe('t1');
|
||||
expect(new URL(plan.connectUrl).searchParams.get('autoConnect')).toBe('1');
|
||||
});
|
||||
|
||||
it('scopes the rule to the relay port', () => {
|
||||
// An unscoped `MAP localhost <host>` captures EVERY localhost port in the
|
||||
// browser, which would swallow unrelated loopback services.
|
||||
const plan = planRelayConnection(
|
||||
connectUrl('ws://localhost:5678/rest/x/extension/abc?token=t1'),
|
||||
'http://n8n-2:5678',
|
||||
);
|
||||
expect(plan.hostResolverRule).toMatch(/^MAP localhost:5678 /);
|
||||
});
|
||||
|
||||
it("retargets the relay onto n8n's port when they disagree", () => {
|
||||
const plan = planRelayConnection(
|
||||
connectUrl('ws://localhost:5678/rest/x/extension/abc?token=t1'),
|
||||
'http://n8n:5679',
|
||||
);
|
||||
expect(new URL(relayOf(plan.connectUrl)).port).toBe('5679');
|
||||
expect(plan.hostResolverRule).toBe('MAP localhost:5679 n8n:5679');
|
||||
});
|
||||
|
||||
it('defaults the port from the scheme when the base URL omits it', () => {
|
||||
const plan = planRelayConnection(
|
||||
connectUrl('ws://localhost:5678/rest/x/extension/abc?token=t1'),
|
||||
'https://n8n.internal',
|
||||
);
|
||||
expect(plan.hostResolverRule).toBe('MAP localhost:443 n8n.internal:443');
|
||||
});
|
||||
|
||||
it('leaves a connect URL with no relay param untouched', () => {
|
||||
const plan = planRelayConnection(`${EXT}?autoConnect=1`, 'http://n8n:5678');
|
||||
expect(plan.connectUrl).toBe(`${EXT}?autoConnect=1`);
|
||||
expect(plan.hostResolverRule).toBeUndefined();
|
||||
});
|
||||
|
||||
it('degrades to a no-op on unparseable input rather than throwing', () => {
|
||||
// A malformed URL must not take the whole run down before the agent starts.
|
||||
expect(planRelayConnection('not a url', 'http://n8n:5678')).toEqual({
|
||||
connectUrl: 'not a url',
|
||||
});
|
||||
expect(planRelayConnection(connectUrl('ws://localhost:5678/x'), 'nope')).toEqual({
|
||||
connectUrl: connectUrl('ws://localhost:5678/x'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('planRelayConnection — port mismatches on the same host', () => {
|
||||
it('retargets the port when n8n is published on a different one, with NO dns rule', () => {
|
||||
// The common local shape: n8n in a container thinks it is on :5678 while
|
||||
// the host reaches it on the published :5680. Same host, so a MAP would
|
||||
// be noise — only the port needs rewriting.
|
||||
const plan = planRelayConnection(
|
||||
connectUrl('ws://localhost:5678/rest/x/extension/abc?token=t1'),
|
||||
'http://localhost:5680',
|
||||
);
|
||||
const relay = new URL(relayOf(plan.connectUrl));
|
||||
expect(relay.hostname).toBe('localhost');
|
||||
expect(relay.port).toBe('5680');
|
||||
expect(plan.hostResolverRule).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats 127.0.0.1 as loopback too — port rewrite, no rule', () => {
|
||||
const plan = planRelayConnection(
|
||||
connectUrl('ws://localhost:5678/rest/x/extension/abc?token=t1'),
|
||||
'http://127.0.0.1:5680',
|
||||
);
|
||||
expect(new URL(relayOf(plan.connectUrl)).port).toBe('5680');
|
||||
expect(plan.hostResolverRule).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still emits a rule when the host differs, even if the port matches', () => {
|
||||
const plan = planRelayConnection(
|
||||
connectUrl('ws://localhost:5678/rest/x/extension/abc?token=t1'),
|
||||
'http://n8n:5678',
|
||||
);
|
||||
expect(plan.hostResolverRule).toBe('MAP localhost:5678 n8n:5678');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixtureInterceptionArgs', () => {
|
||||
const FIXTURE_RULES = 'MAP console.anthropic.com 127.0.0.1:8443,MAP * 127.0.0.1:8443';
|
||||
const rulesOf = (args: string[]) =>
|
||||
args
|
||||
.find((a) => a.startsWith('--host-resolver-rules='))
|
||||
?.split('=')
|
||||
.slice(1)
|
||||
.join('=') ?? '';
|
||||
|
||||
it('never disables certificate checking for a real-site run', () => {
|
||||
// The cert flag exists only for the fixture's self-signed cert. Local mode
|
||||
// browses the real internet in the developer's own profile.
|
||||
expect(fixtureInterceptionArgs(undefined, undefined)).toEqual([]);
|
||||
expect(fixtureInterceptionArgs(undefined, 'MAP localhost:5680 n8n:5678')).not.toContain(
|
||||
'--ignore-certificate-errors',
|
||||
);
|
||||
});
|
||||
|
||||
it('pairs the cert flag with the fixture rules, never one without the other', () => {
|
||||
expect(fixtureInterceptionArgs(FIXTURE_RULES, undefined)).toContain(
|
||||
'--ignore-certificate-errors',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes ONE resolver flag — a second silently drops the first', () => {
|
||||
const args = fixtureInterceptionArgs(FIXTURE_RULES, 'MAP localhost:5680 n8n:5678');
|
||||
expect(args.filter((a) => a.startsWith('--host-resolver-rules='))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('orders the relay rule ahead of the catch-all, since first match wins', () => {
|
||||
const rules = rulesOf(fixtureInterceptionArgs(FIXTURE_RULES, 'MAP localhost:5680 n8n:5678'));
|
||||
expect(rules.indexOf('MAP localhost:5680')).toBeLessThan(rules.indexOf('MAP *'));
|
||||
});
|
||||
|
||||
it('excludes every loopback spelling the extension accepts, not just localhost', () => {
|
||||
// The relay can be reached as 127.0.0.1 or [::1] too (relayAllowlist's
|
||||
// LOCAL_HOSTS); leaving those to the catch-all would swallow the relay.
|
||||
const rules = rulesOf(fixtureInterceptionArgs(FIXTURE_RULES, undefined));
|
||||
expect(rules).toContain('EXCLUDE localhost');
|
||||
expect(rules).toContain('EXCLUDE 127.0.0.1');
|
||||
expect(rules).toContain('EXCLUDE [::1]');
|
||||
});
|
||||
|
||||
it('keeps the other loopback excludes even when a relay rule is present', () => {
|
||||
// The relay MAP is PORT-scoped, so other loopback ports would still fall
|
||||
// through to the wildcard without these. Neither spelling collides with a
|
||||
// `MAP localhost:<port>` rule — exclusions match on hostname.
|
||||
const rules = rulesOf(fixtureInterceptionArgs(FIXTURE_RULES, 'MAP localhost:5680 n8n:5678'));
|
||||
expect(rules).toContain('EXCLUDE 127.0.0.1');
|
||||
expect(rules).toContain('EXCLUDE [::1]');
|
||||
});
|
||||
|
||||
it('drops the localhost exclude when a relay rule needs that hostname', () => {
|
||||
// Chromium checks EXCLUDEs before MAPs and returns on the first match, so
|
||||
// `EXCLUDE localhost` vetoes `MAP localhost:<port> …` no matter where it
|
||||
// sits in the string (verified against Chromium 1223: the MAP applies
|
||||
// alone, and stops applying in either order once the exclude is added).
|
||||
// Emitting both left the extension resolving localhost inside its own
|
||||
// container instead of reaching n8n.
|
||||
const rules = rulesOf(fixtureInterceptionArgs(FIXTURE_RULES, 'MAP localhost:5680 n8n:5678'));
|
||||
expect(rules).not.toContain('EXCLUDE localhost');
|
||||
expect(rules).toContain('MAP localhost:5680 n8n:5678');
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { join } from 'path';
|
||||
|
||||
import { parseCliArgs } from './args';
|
||||
import { loadTestCases } from '../data/source';
|
||||
import { LOCAL_FIXTURE_ID } from '../harness/credential-setup-lane';
|
||||
import { createLogger } from '../harness/logger';
|
||||
import { type McpBuildSpend } from '../run/build-orchestrator';
|
||||
import { selectCases } from '../run/case-selection';
|
||||
@@ -23,6 +24,12 @@ import { runWithLangSmith } from '../run/langsmith-driver';
|
||||
import { ciRerunHint, createRowSink, runEvalAndPersist } from '../run/persist';
|
||||
import { emitRunReports } from '../run/reporters';
|
||||
|
||||
/** Whether more than one browser BUILD can exist in this run — the relay is
|
||||
* instance-wide, and iterations expand into separate concurrent rows. */
|
||||
export function serialiseForBrowserLane(browserCaseCount: number, iterations: number): boolean {
|
||||
return browserCaseCount > 1 || (browserCaseCount > 0 && iterations > 1);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseCliArgs(process.argv.slice(2));
|
||||
const logger = createLogger(args.verbose);
|
||||
@@ -33,6 +40,47 @@ async function main(): Promise<void> {
|
||||
logger,
|
||||
);
|
||||
|
||||
// A `local` case drives the developer's own browser against the real provider.
|
||||
// That cannot be parallelised: concurrency defaults to 16, lanes cap at 4 and
|
||||
// iterations multiply again, and a single Chrome profile cannot be opened
|
||||
// twice. Serialise, and refuse a multi-case selection outright rather than
|
||||
// opening windows nobody is watching. Enforced HERE because the case count is
|
||||
// only known after selectCases.
|
||||
const localCases = testCasesWithFiles.filter(
|
||||
({ testCase }) => testCase.credentialFixture === LOCAL_FIXTURE_ID,
|
||||
);
|
||||
if (localCases.length > 0) {
|
||||
if (testCasesWithFiles.length > 1) {
|
||||
throw new Error(
|
||||
`credentialFixture "${LOCAL_FIXTURE_ID}" drives your real browser, so it runs one case at a time — ` +
|
||||
`the current selection has ${String(testCasesWithFiles.length)}. Narrow it with --filter.`,
|
||||
);
|
||||
}
|
||||
if (args.iterations > 1) {
|
||||
throw new Error(
|
||||
`credentialFixture "${LOCAL_FIXTURE_ID}" cannot run multiple iterations — each one creates a REAL credential.`,
|
||||
);
|
||||
}
|
||||
args.concurrency = 1;
|
||||
logger.info(' Local mode: serialised, and every run creates a REAL credential.');
|
||||
}
|
||||
|
||||
// Every browser-lane case shares ONE resource: the instance's single relay.
|
||||
// `createBrowserLink()` / `disconnectBrowserSession()` are instance-wide, so
|
||||
// a second concurrent browser build displaces the first and either build's
|
||||
// tools can end up driving the other's browser. A single case at one
|
||||
// iteration cannot collide with itself, so the rest of the run keeps its
|
||||
// parallelism.
|
||||
const browserCases = testCasesWithFiles.filter(
|
||||
({ testCase }) => testCase.credentialFixture !== undefined,
|
||||
);
|
||||
if (serialiseForBrowserLane(browserCases.length, args.iterations) && args.concurrency !== 1) {
|
||||
args.concurrency = 1;
|
||||
logger.info(
|
||||
` ${String(browserCases.length)} browser-lane case(s) selected: serialised, because the n8n relay is instance-wide.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Per-build `claude` logs (--build-via-mcp only). One shared dir; filenames
|
||||
// are slug/iteration/attempt-scoped so concurrent lanes never collide.
|
||||
const mcpBuildLogDir = args.buildViaMcp
|
||||
|
||||
@@ -192,6 +192,9 @@ export const MCP_BUILD_KEY_SUPPORT: Record<
|
||||
// user's personal project (the standalone manifest builder cannot — it has
|
||||
// no n8n session).
|
||||
credentials: 'supported',
|
||||
// The fixture server, Chromium and the relay are booted around the build;
|
||||
// `claude` gets a flattened prompt and no browser.
|
||||
credentialFixture: 'orchestrator-only',
|
||||
seed: 'orchestrator-only',
|
||||
datasets: 'supported',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Serve a provider fixture on its own, so a human can look at the page the
|
||||
// agent sees.
|
||||
//
|
||||
// The lane's fixture server is per-run and dies with the case, on a random
|
||||
// port behind host-mapping — fine for the eval, useless for eyeballing. This
|
||||
// boots the SAME `startFixtureServer` and just leaves it up.
|
||||
//
|
||||
// Also the mechanism WS9's drift refresh needs: to re-check a lookalike page
|
||||
// against the real console you have to be able to open it.
|
||||
//
|
||||
// pnpm -F @n8n/instance-ai eval:serve-fixture -- --fixture anthropic
|
||||
//
|
||||
// Routing is by PATH, so plain `https://127.0.0.1:<port>/settings/keys` works;
|
||||
// the cert is self-signed, so the browser will warn once.
|
||||
|
||||
import { loadProviderFixtures, startFixtureServer } from '../harness/fixture-server';
|
||||
import { createLogger } from '../harness/logger';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
const at = argv.indexOf('--fixture');
|
||||
const wanted = at >= 0 ? argv[at + 1] : undefined;
|
||||
|
||||
const fixtures = await loadProviderFixtures();
|
||||
const available = fixtures.map((f) => f.id).join(', ') || '(none)';
|
||||
|
||||
const fixture = wanted ? fixtures.find((f) => f.id === wanted) : undefined;
|
||||
if (!fixture) {
|
||||
console.error(
|
||||
wanted
|
||||
? `No fixture "${wanted}". Available: ${available}`
|
||||
: `Pass --fixture <id>. Available: ${available}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = await startFixtureServer({ fixture, logger: createLogger(true) });
|
||||
|
||||
console.log(`\n fixture: ${fixture.id} (${fixture.manifest.credentialType})`);
|
||||
console.log(` stands in for: ${fixture.manifest.hosts.join(', ')}`);
|
||||
console.log(` minted secret for this session: ${server.mintedSecret}`);
|
||||
console.log('\n Open (accept the self-signed cert):');
|
||||
for (const route of Object.keys(fixture.manifest.routes)) {
|
||||
console.log(` https://127.0.0.1:${String(server.port)}${route}`);
|
||||
}
|
||||
console.log('\n Ctrl-C to stop.\n');
|
||||
|
||||
const stop = () => {
|
||||
void server.close().then(() => process.exit(0));
|
||||
};
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -100,10 +100,37 @@ const GatewayStatusSchema = z.object({
|
||||
const GatewayStatusEnvelope = z.object({ data: GatewayStatusSchema });
|
||||
export type GatewayStatus = z.infer<typeof GatewayStatusSchema>;
|
||||
|
||||
// Browser-use relay (a different channel from the computer-use gateway above:
|
||||
// the server owns the CDP relay and the extension dials in).
|
||||
const BrowserLinkSchema = z.object({
|
||||
connectUrl: z.string(),
|
||||
expiresAt: z.string().nullable(),
|
||||
ttlSeconds: z.number().nullable(),
|
||||
});
|
||||
const BrowserLinkEnvelope = z.object({ data: BrowserLinkSchema });
|
||||
export type BrowserLink = z.infer<typeof BrowserLinkSchema>;
|
||||
|
||||
const BrowserStatusSchema = z.object({
|
||||
connected: z.boolean(),
|
||||
connectedAt: z.string().nullable(),
|
||||
toolCategories: z.array(z.object({ name: z.string(), enabled: z.boolean() })),
|
||||
});
|
||||
const BrowserStatusEnvelope = z.object({ data: BrowserStatusSchema });
|
||||
export type BrowserStatus = z.infer<typeof BrowserStatusSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response shapes from the n8n REST API (wrapped in { data: ... })
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A credential as `GET /rest/credentials` returns it. No `data`: the REST read
|
||||
* blanks every password field, so nothing here consumes decrypted credential
|
||||
* data — see the header of `credential-setup-checks.ts`. */
|
||||
export interface CredentialResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/** A node as returned by the n8n REST API — the fields eval code reads. */
|
||||
export interface WorkflowNodeResponse {
|
||||
id?: string;
|
||||
@@ -203,7 +230,9 @@ export class N8nApiError extends Error {
|
||||
export class N8nClient {
|
||||
private sessionCookie?: string;
|
||||
|
||||
constructor(private readonly baseUrl: string) {}
|
||||
/** Public: the browser runtime needs to know where n8n ACTUALLY is, which is
|
||||
* not always what n8n reports as its own base URL (see `planRelayConnection`). */
|
||||
constructor(readonly baseUrl: string) {}
|
||||
|
||||
// -- Auth ----------------------------------------------------------------
|
||||
|
||||
@@ -362,6 +391,36 @@ export class N8nClient {
|
||||
return GatewayStatusEnvelope.parse(result).data;
|
||||
}
|
||||
|
||||
// -- Browser-use relay (extension pairing + status) ----------------------
|
||||
|
||||
/**
|
||||
* Mint a connect URL for the browser-use extension to dial into. This is the
|
||||
* production `mode: 'remote'` path — the server owns the relay.
|
||||
* POST /rest/instance-ai/browser/create-link
|
||||
*/
|
||||
async createBrowserLink(): Promise<BrowserLink> {
|
||||
const result = await this.fetch('/rest/instance-ai/browser/create-link', { method: 'POST' });
|
||||
return BrowserLinkEnvelope.parse(result).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the browser relay status. Flips to `connected: true` once the
|
||||
* extension has registered.
|
||||
* GET /rest/instance-ai/browser/status
|
||||
*/
|
||||
async getBrowserStatus(): Promise<BrowserStatus> {
|
||||
const result = await this.fetch('/rest/instance-ai/browser/status');
|
||||
return BrowserStatusEnvelope.parse(result).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the browser session so the next case starts from a clean relay.
|
||||
* POST /rest/instance-ai/browser/disconnect-session
|
||||
*/
|
||||
async disconnectBrowserSession(): Promise<void> {
|
||||
await this.fetch('/rest/instance-ai/browser/disconnect-session', { method: 'POST' });
|
||||
}
|
||||
|
||||
// -- REST API (verification helpers) -------------------------------------
|
||||
|
||||
/**
|
||||
@@ -391,12 +450,51 @@ export class N8nClient {
|
||||
return { id: result.data.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all credentials visible to the authenticated user (no secret data).
|
||||
* GET /rest/credentials
|
||||
*/
|
||||
async listCredentials(): Promise<CredentialResponse[]> {
|
||||
const result = (await this.fetch('/rest/credentials')) as { data: CredentialResponse[] };
|
||||
return Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a credential's own test request WITHOUT persisting anything.
|
||||
* POST /rest/credentials/test
|
||||
*
|
||||
* Proves the stored secret works without the harness ever reading it back.
|
||||
*/
|
||||
async testCredential(credential: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
}): Promise<{ status: string; message?: string }> {
|
||||
const result = (await this.fetch('/rest/credentials/test', {
|
||||
method: 'POST',
|
||||
body: { credentials: credential },
|
||||
})) as { data?: { status?: string; message?: string } };
|
||||
return { status: result.data?.status ?? 'Error', message: result.data?.message };
|
||||
}
|
||||
|
||||
/** Read one credential including its (password-blanked) data — the shape the
|
||||
* test endpoint wants echoed back. */
|
||||
async getCredentialForTest(id: string): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
}> {
|
||||
const result = (await this.fetch(`/rest/credentials/${id}?includeData=true`)) as {
|
||||
data: { id: string; name: string; type: string; data?: Record<string, unknown> };
|
||||
};
|
||||
return { ...result.data, data: result.data.data ?? {} };
|
||||
}
|
||||
|
||||
/** List all credential IDs visible to the authenticated user. */
|
||||
async listCredentialIds(): Promise<string[]> {
|
||||
const result = (await this.fetch('/rest/credentials')) as {
|
||||
data: Array<{ id: string }>;
|
||||
};
|
||||
return Array.isArray(result.data) ? result.data.map((c) => c.id) : [];
|
||||
return (await this.listCredentials()).map((c) => c.id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"description": "Credential-setup case: the assistant sets up an Anthropic API credential by driving a browser to the provider console, capturing the key it creates there, and saving it in n8n \u2014 without ever putting the secret in the chat. Build-only: no execution scenarios. The provider console is a lookalike fixture served AS console.anthropic.com (see evaluations/fixtures/providers/anthropic), so the run is hermetic and the exact key value is known to the harness. Three deterministic checks (credential created / the saved credential authenticates against the fixture's provider stand-in, which accepts only the minted key / secret never leaked) are injected alongside these expectations. The value check DISCARDS itself when no stand-in is reachable. Requires the credential-setup browser lane: an extension-capable Chromium and Browser Use enabled on the eval instance.",
|
||||
"credentialFixture": "anthropic",
|
||||
"conversation": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Use my browser to set up an Anthropic API credential in n8n. Open the Anthropic console, create a new API key there, and save it as a credential for me."
|
||||
}
|
||||
],
|
||||
"complexity": "medium",
|
||||
"tags": ["browser-use", "anthropic"],
|
||||
"processExpectations": [
|
||||
"The assistant uses its browser tools to reach the Anthropic console's API keys page, rather than asking the user to go there and paste a key back",
|
||||
"The assistant creates a new API key on the provider page instead of reusing or inventing one",
|
||||
"The assistant saves the captured key as an n8n credential using its credential-creation tool",
|
||||
"The assistant never asks the user to paste an API key into the chat, and never repeats the key value back to the user",
|
||||
"The assistant tells the user plainly what it did and which credential now exists"
|
||||
],
|
||||
"datasets": ["full"]
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Lookalike API-keys console for credential-setup evals. Our own generic content —
|
||||
no provider source, no recorded responses, no real tokens.
|
||||
|
||||
CALIBRATED against a real captured accessibility tree (LangTracer thread
|
||||
3c764a14, page https://platform.claude.com/settings/keys). Landmarks taken from
|
||||
that capture, so the agent sees the same shape it sees in production:
|
||||
heading "API keys <count>" [level=1] · button "Create Key" · textbox "Search keys"
|
||||
table with sortable columnheaders: Key / Workspace / Created by / Created /
|
||||
Expires / Last used / Actions
|
||||
existing keys rendered as `code` with a TRUNCATED value (sk-ant-api03-2Zx...egAA)
|
||||
dialog "Create API key" [level=2]: combobox "Workspace", textbox "Name",
|
||||
combobox "Expires", button "Add", button "Close"
|
||||
dialog "Save your API key" [level=2]: button "Copy Key", button "Done"
|
||||
|
||||
Two properties that are load-bearing, both taken from the real page:
|
||||
1. The list is PRE-POPULATED with truncated keys. An agent that grabs a
|
||||
displayed value instead of the freshly issued one captures an ellipsised
|
||||
string — a real failure mode, and one the exact-value check is meant to
|
||||
catch. An empty list would hide it.
|
||||
2. The submit button is "Add", NOT "Create Key" — "Create Key" only opens the
|
||||
dialog. An agent that clicks the wrong one never creates anything.
|
||||
|
||||
The captured tree was German (the user's browser locale). Rendered in English
|
||||
here because the eval corpus is English; re-check names if a locale-specific
|
||||
run is ever needed.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>API keys - Claude Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<nav aria-label="Main navigation">
|
||||
<button type="button">Collapse</button>
|
||||
<a href="/dashboard">Claude Console</a>
|
||||
<a href="/dashboard">Back to app</a>
|
||||
<button type="button">Search console…</button>
|
||||
<ul>
|
||||
<li><a href="/settings/general">General</a></li>
|
||||
<li><a href="/settings/organization">Organization</a></li>
|
||||
<li><a href="/settings/members">Members</a></li>
|
||||
<li><a href="/settings/workspaces">Workspaces</a></li>
|
||||
<li><a href="/settings/limits">Limits</a></li>
|
||||
<li><a href="/settings/keys" aria-current="page">API keys</a></li>
|
||||
<li><a href="/settings/service-accounts">Service Accounts</a></li>
|
||||
<li><a href="/settings/workload-identity">Workload Identity</a></li>
|
||||
<li><a href="/docs">Documentation</a></li>
|
||||
</ul>
|
||||
<button type="button">Eval Owner n8n GmbH</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<h1>API keys <span id="key-count">8</span></h1>
|
||||
|
||||
<button id="create-key" type="button">Create Key</button>
|
||||
<label for="search-keys">Search keys</label>
|
||||
<input id="search-keys" name="search-keys" type="text" />
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"><button type="button">Key</button></th>
|
||||
<th scope="col"><button type="button">Workspace</button></th>
|
||||
<th scope="col"><button type="button">Created by</button></th>
|
||||
<th scope="col"><button type="button">Created</button></th>
|
||||
<th scope="col"><button type="button">Expires</button></th>
|
||||
<th scope="col"><button type="button">Last used</button></th>
|
||||
<th scope="col"><button type="button">Actions</button></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="key-rows">
|
||||
<!-- Pre-existing keys, shown truncated exactly as the real console does. -->
|
||||
<tr><td><code>sk-ant-api03-2Zx...egAA</code></td><td>Default</td><td>Eval Owner</td><td>Mar 3, 2026</td><td>Never</td><td>Apr 1, 2026</td><td><button type="button">Delete</button></td></tr>
|
||||
<tr><td><code>sk-ant-api03-PCa...GwAA</code></td><td>Default</td><td>Eval Owner</td><td>Mar 8, 2026</td><td>Never</td><td>Mar 30, 2026</td><td><button type="button">Delete</button></td></tr>
|
||||
<tr><td><code>sk-ant-api03-FuC...CgAA</code></td><td>Default</td><td>Eval Owner</td><td>Apr 2, 2026</td><td>Never</td><td>—</td><td><button type="button">Delete</button></td></tr>
|
||||
<tr><td><code>sk-ant-api03-jOy...EgAA</code></td><td>Staging</td><td>Eval Owner</td><td>Apr 19, 2026</td><td>Never</td><td>May 2, 2026</td><td><button type="button">Delete</button></td></tr>
|
||||
<tr><td><code>sk-ant-api03-Oxa...jgAA</code></td><td>Default</td><td>Eval Owner</td><td>May 6, 2026</td><td>Never</td><td>—</td><td><button type="button">Delete</button></td></tr>
|
||||
<tr><td><code>sk-ant-api03-Nvq...nQAA</code></td><td>Default</td><td>Eval Owner</td><td>May 21, 2026</td><td>Never</td><td>Jun 9, 2026</td><td><button type="button">Delete</button></td></tr>
|
||||
<tr><td><code>sk-ant-api03-8YM...aQAA</code></td><td>Production</td><td>Eval Owner</td><td>Jun 11, 2026</td><td>Never</td><td>Jul 3, 2026</td><td><button type="button">Delete</button></td></tr>
|
||||
<tr><td><code>sk-ant-api03-5os..._QAA</code></td><td>Default</td><td>Eval Owner</td><td>Jul 1, 2026</td><td>Never</td><td>—</td><td><button type="button">Delete</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Creation dialog — opened by "Create Key"; submit is "Add". -->
|
||||
<div id="create-dialog" role="dialog" aria-labelledby="create-title" aria-modal="true" hidden>
|
||||
<h2 id="create-title">Create API key</h2>
|
||||
<button id="create-close" type="button">Close</button>
|
||||
<label for="workspace">Workspace</label>
|
||||
<select id="workspace" name="workspace">
|
||||
<option value="default" selected>Default</option>
|
||||
<option value="staging">Staging</option>
|
||||
<option value="production">Production</option>
|
||||
</select>
|
||||
<label for="key-name">Name</label>
|
||||
<input id="key-name" name="key-name" type="text" value="" />
|
||||
<label for="expires">Expires</label>
|
||||
<select id="expires" name="expires">
|
||||
<option value="never" selected>Never</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
</select>
|
||||
<button id="create-submit" type="button">Add</button>
|
||||
</div>
|
||||
|
||||
<!-- Reveal dialog — the ONLY place the full key ever appears. -->
|
||||
<div id="reveal" role="dialog" aria-labelledby="reveal-title" aria-modal="true" hidden>
|
||||
<h2 id="reveal-title">Save your API key</h2>
|
||||
<p>This is the only time the full key will be shown.</p>
|
||||
<label for="key-value">API key</label>
|
||||
<input id="key-value" name="key-value" type="text" readonly value="" />
|
||||
<button id="copy-key" type="button">Copy Key</button>
|
||||
<button id="reveal-done" type="button">Done</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const dialog = document.getElementById('create-dialog');
|
||||
const reveal = document.getElementById('reveal');
|
||||
const keyValue = document.getElementById('key-value');
|
||||
const nameInput = document.getElementById('key-name');
|
||||
|
||||
document.getElementById('create-key').addEventListener('click', () => {
|
||||
dialog.hidden = false;
|
||||
});
|
||||
document.getElementById('create-close').addEventListener('click', () => {
|
||||
dialog.hidden = true;
|
||||
});
|
||||
|
||||
document.getElementById('create-submit').addEventListener('click', async () => {
|
||||
const response = await fetch('/__fixture__/create-key', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: nameInput.value || 'Untitled',
|
||||
workspace: document.getElementById('workspace').value,
|
||||
expires: document.getElementById('expires').value,
|
||||
}),
|
||||
});
|
||||
const { key } = await response.json();
|
||||
keyValue.value = key;
|
||||
dialog.hidden = true;
|
||||
reveal.hidden = false;
|
||||
|
||||
// New row shows the TRUNCATED form, like the real console.
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML =
|
||||
'<td><code>' +
|
||||
key.slice(0, 16) +
|
||||
'...' +
|
||||
key.slice(-4) +
|
||||
'</code></td><td>' +
|
||||
document.getElementById('workspace').value +
|
||||
'</td><td>Eval Owner</td><td>just now</td><td>Never</td><td>—</td>' +
|
||||
'<td><button type="button">Delete</button></td>';
|
||||
document.getElementById('key-rows').prepend(row);
|
||||
const count = document.getElementById('key-count');
|
||||
count.textContent = String(Number(count.textContent) + 1);
|
||||
});
|
||||
|
||||
document.getElementById('copy-key').addEventListener('click', async () => {
|
||||
await navigator.clipboard.writeText(keyValue.value).catch(() => {});
|
||||
});
|
||||
document.getElementById('reveal-done').addEventListener('click', () => {
|
||||
reveal.hidden = true;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Lookalike provider landing page for credential-setup evals. Our own generic
|
||||
content — no provider source, no recorded responses, no real tokens.
|
||||
|
||||
It exists so the agent has to NAVIGATE to the API-keys page rather than
|
||||
starting on it: reaching the right page is the step that broke in NODE-5384,
|
||||
so a fixture that opens directly on the key list would test past the bug.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Dashboard - Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav aria-label="Main">
|
||||
<ul>
|
||||
<li><a href="/dashboard" aria-current="page">Dashboard</a></li>
|
||||
<li><a href="/settings/keys">API keys</a></li>
|
||||
<li><a href="/settings/billing">Billing</a></li>
|
||||
<li><a href="/settings/limits">Rate limits</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1>Dashboard</h1>
|
||||
<section aria-labelledby="get-started">
|
||||
<h2 id="get-started">Get started</h2>
|
||||
<p>
|
||||
Create an API key to start making requests. Manage keys from
|
||||
<a href="/settings/keys">API keys</a>.
|
||||
</p>
|
||||
</section>
|
||||
<section aria-labelledby="usage">
|
||||
<h2 id="usage">Usage this month</h2>
|
||||
<p>No usage recorded.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"credentialType": "anthropicApi",
|
||||
"hosts": ["platform.claude.com", "console.anthropic.com"],
|
||||
"secretPrefix": "sk-ant-api03-",
|
||||
"routes": {
|
||||
"/settings/keys": "console.html",
|
||||
"/dashboard": "dashboard.html"
|
||||
},
|
||||
"defaultRoute": "/dashboard",
|
||||
"verify": {
|
||||
"path": "/v1/models",
|
||||
"header": "x-api-key"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Browser runtime for credential-setup evals — a headless Chromium running the
|
||||
// REAL browser-use extension, attached to the n8n server's own relay.
|
||||
//
|
||||
// This is the PRODUCTION path, not an imitation of it. Production browser use
|
||||
// is `mode: 'remote'`: the n8n server owns the CDP relay and the extension
|
||||
// dials in (`mcp-browser/src/adapters/playwright.ts:112-120`, composed into the
|
||||
// agent's tool scope at `instance-ai.service.ts:2116-2119`). So the harness only
|
||||
// has to supply a browser with the extension in it — nothing in `@n8n/mcp-browser`,
|
||||
// the extension, or the relay changes.
|
||||
//
|
||||
// Deliberately NOT reusing mcp-browser's local-mode spawn: that path is
|
||||
// `execFile(chromePath, [connectUrl])` (`playwright.ts:148`) and passes no
|
||||
// flags, so `--load-extension` / `--host-resolver-rules` could not ride it, and
|
||||
// local mode is not what production uses anyway.
|
||||
//
|
||||
// Pay-per-use: this boots per case in the credential-setup lane and dies with
|
||||
// it. No other suite ever starts a browser.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { getDefaultDiscovery } from '@n8n/mcp-browser';
|
||||
import fastGlob from 'fast-glob';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { chromium, type BrowserContext } from 'playwright-core';
|
||||
|
||||
import type { EvalLogger } from './logger';
|
||||
import type { N8nClient } from '../clients/n8n-client';
|
||||
|
||||
/** Built extension directory, relative to this file. */
|
||||
const EXTENSION_DIST = join(__dirname, '..', '..', '..', 'mcp-browser-extension', 'dist');
|
||||
|
||||
/**
|
||||
* Locate a Chromium that can load an extension.
|
||||
*
|
||||
* Playwright's default download is `chromium_headless_shell-*`, which CANNOT
|
||||
* load extensions — that is the whole reason this helper exists rather than
|
||||
* calling `chromium.launch()` and hoping. Full Chromium's modern headless mode
|
||||
* does support them (verified: the MV3 service worker registers and
|
||||
* `chrome-extension://<id>/connect.html` serves 200).
|
||||
*/
|
||||
export function findChromiumForEval(): string {
|
||||
const override = process.env.N8N_EVAL_BROWSER_EXECUTABLE;
|
||||
if (override) {
|
||||
if (!existsSync(override)) {
|
||||
throw new Error(`N8N_EVAL_BROWSER_EXECUTABLE is set but missing: ${override}`);
|
||||
}
|
||||
return override;
|
||||
}
|
||||
|
||||
// Playwright's cache — `chromium-*` only; `chromium_headless_shell-*` is
|
||||
// excluded by the glob, not by accident.
|
||||
const cacheRoots = [
|
||||
join(homedir(), 'Library', 'Caches', 'ms-playwright'),
|
||||
join(homedir(), '.cache', 'ms-playwright'),
|
||||
];
|
||||
for (const root of cacheRoots) {
|
||||
if (!existsSync(root)) continue;
|
||||
const matches = fastGlob.sync(
|
||||
[
|
||||
'chromium-*/chrome-mac*/*.app/Contents/MacOS/*',
|
||||
'chromium-*/chrome-linux*/chrome',
|
||||
'chromium-*/chrome-win*/chrome.exe',
|
||||
],
|
||||
{ cwd: root, absolute: true, onlyFiles: true, deep: 6 },
|
||||
);
|
||||
// Numeric compare: lexically `chromium-999` sorts above `chromium-1223`.
|
||||
const best = matches.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).at(-1);
|
||||
if (best) return best;
|
||||
}
|
||||
|
||||
const installed = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
];
|
||||
for (const candidate of installed) if (existsSync(candidate)) return candidate;
|
||||
|
||||
throw new Error(
|
||||
'No extension-capable Chromium found. Install one (`pnpm exec playwright install chromium`) ' +
|
||||
'or point N8N_EVAL_BROWSER_EXECUTABLE at a full Chrome/Chromium binary. ' +
|
||||
"Playwright's headless *shell* cannot load extensions and is deliberately not used.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out what connect URL to load, and whether the browser needs a DNS rule
|
||||
* to make it reach n8n.
|
||||
*
|
||||
* The extension only honours `autoConnect` when the relay URL's host is
|
||||
* localhost (`relayAllowlist.ts` — a deliberate gate against a page pointing a
|
||||
* user's browser at someone else's relay). When the harness runs beside n8n
|
||||
* that is simply true and nothing here applies.
|
||||
*
|
||||
* It stops being true when the harness runs in a SEPARATE container from n8n —
|
||||
* the lang-tracer dispatcher. n8n still reports its base URL as `localhost`
|
||||
* (compose sets no `N8N_EDITOR_BASE_URL`), so the gate passes, but that name
|
||||
* resolves to the HARNESS's own container and the extension connects to
|
||||
* nothing. So keep the URL saying localhost — the gate is about what the page
|
||||
* was handed — and redirect that one host:port onto the real n8n at the DNS
|
||||
* layer.
|
||||
*
|
||||
* Port-scoped deliberately: an unscoped `MAP localhost <host>:<port>` captures
|
||||
* EVERY localhost port in the browser (probe-verified), which would swallow
|
||||
* any other loopback service the run depends on — the fixture server included,
|
||||
* if it were ever addressed by name rather than by provider hostname.
|
||||
*/
|
||||
export function planRelayConnection(
|
||||
connectUrl: string,
|
||||
n8nBaseUrl: string,
|
||||
): { connectUrl: string; hostResolverRule?: string } {
|
||||
let url: URL;
|
||||
let target: URL;
|
||||
try {
|
||||
url = new URL(connectUrl);
|
||||
target = new URL(n8nBaseUrl);
|
||||
} catch {
|
||||
return { connectUrl };
|
||||
}
|
||||
|
||||
const relayRaw = url.searchParams.get('mcpRelayUrl');
|
||||
if (!relayRaw) return { connectUrl };
|
||||
let relay: URL;
|
||||
try {
|
||||
relay = new URL(relayRaw);
|
||||
} catch {
|
||||
return { connectUrl };
|
||||
}
|
||||
|
||||
const port = target.port || (target.protocol === 'https:' ? '443' : '80');
|
||||
// Nothing to do only when the relay URL ALREADY points at n8n — host AND
|
||||
// port. A port mismatch is the common local case: n8n in a container thinks
|
||||
// it is on :5678 while the host reaches it on the published port.
|
||||
if (relay.hostname === target.hostname && relay.port === port) return { connectUrl };
|
||||
|
||||
relay.hostname = 'localhost';
|
||||
relay.port = port;
|
||||
url.searchParams.set('mcpRelayUrl', relay.toString());
|
||||
|
||||
// DNS help is only needed when n8n is on a DIFFERENT host. If it is reachable
|
||||
// on loopback, rewriting the port is enough and a MAP would be noise.
|
||||
const needsDnsRule = !LOOPBACK_HOSTS.has(target.hostname);
|
||||
return {
|
||||
connectUrl: url.toString(),
|
||||
hostResolverRule: needsDnsRule ? `MAP localhost:${port} ${target.hostname}:${port}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Hosts the extension already treats as local, so no DNS redirect is needed. */
|
||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
||||
|
||||
/** Loopback spellings the extension's own relay allowlist accepts. All of them
|
||||
* must escape the fixture's catch-all, not just the literal "localhost". */
|
||||
const LOOPBACK_EXCLUDES = ['localhost', '127.0.0.1', '[::1]'];
|
||||
|
||||
/**
|
||||
* The two flags that make a fixture run hermetic, assembled in one place so the
|
||||
* ordering rules are testable rather than implied.
|
||||
*
|
||||
* Comma-joined into ONE `--host-resolver-rules`, never passed twice: with two
|
||||
* flags the earlier one's rules are silently dropped (probe-verified — its host
|
||||
* came back ERR_NAME_NOT_RESOLVED).
|
||||
*
|
||||
* ORDER MATTERS AMONG MAPS, first match wins:
|
||||
* 1. the relay rule, so the relay is not swallowed by the wildcard
|
||||
* 2. the fixture's own host maps, then its `MAP *` catch-all
|
||||
* 3. loopback EXCLUDEs
|
||||
*
|
||||
* EXCLUDEs do NOT obey that order: Chromium checks them before any MAP and
|
||||
* returns on the first hit, so `EXCLUDE localhost` vetoes `MAP localhost:<port>`
|
||||
* wherever it sits (verified against Chromium 1223). A relay rule maps that
|
||||
* hostname, so its exclude is dropped when one is present.
|
||||
*
|
||||
* Returns nothing at all for a local run: no interception, and in particular no
|
||||
* `--ignore-certificate-errors`, which exists only for the fixture's
|
||||
* self-signed cert. Applying it to a real-internet run in the developer's own
|
||||
* profile would be a genuine downgrade for no benefit, so the cert flag and the
|
||||
* fixture rules are emitted together or not at all.
|
||||
*/
|
||||
export function fixtureInterceptionArgs(
|
||||
hostResolverRules: string | undefined,
|
||||
relayRule: string | undefined,
|
||||
): string[] {
|
||||
if (!hostResolverRules) return relayRule ? [`--host-resolver-rules=${relayRule}`] : [];
|
||||
const excludes = relayRule
|
||||
? LOOPBACK_EXCLUDES.filter((host) => !relayRule.includes(`MAP ${host}:`))
|
||||
: LOOPBACK_EXCLUDES;
|
||||
const rules = [relayRule, hostResolverRules, ...excludes.map((host) => `EXCLUDE ${host}`)].filter(
|
||||
Boolean,
|
||||
);
|
||||
return [`--host-resolver-rules=${rules.join(',')}`, '--ignore-certificate-errors'];
|
||||
}
|
||||
|
||||
export interface BrowserRuntime {
|
||||
/** The launched context — ONLY for the fixture path. Local mode attaches to
|
||||
* a browser it did not start, so there is nothing to hand back. */
|
||||
context?: BrowserContext;
|
||||
/** Resolves once the extension reports connected to the n8n relay. */
|
||||
connected: boolean;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartBrowserRuntimeOptions {
|
||||
client: N8nClient;
|
||||
logger: EvalLogger;
|
||||
/** From `FixtureServer.hostResolverRules()`. Omit to let the browser reach
|
||||
* the real internet — only correct for attended real-site runs. */
|
||||
hostResolverRules?: string;
|
||||
/** Attended mode: show the browser so a human can log in first. */
|
||||
headed?: boolean;
|
||||
/** How long to wait for the extension to report connected. */
|
||||
connectTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function startBrowserRuntime(
|
||||
options: StartBrowserRuntimeOptions,
|
||||
): Promise<BrowserRuntime> {
|
||||
const { client, logger, hostResolverRules, headed = false } = options;
|
||||
const connectTimeoutMs = options.connectTimeoutMs ?? 30_000;
|
||||
|
||||
if (!existsSync(join(EXTENSION_DIST, 'manifest.json'))) {
|
||||
throw new Error(
|
||||
`Browser-use extension is not built at ${EXTENSION_DIST}. ` +
|
||||
'Run `pnpm -F @n8n/mcp-browser-extension build` first.',
|
||||
);
|
||||
}
|
||||
|
||||
// Mint the relay link BEFORE launching: the connect page auto-connects on
|
||||
// load, so the relay has to be waiting for it.
|
||||
const link = await client.createBrowserLink();
|
||||
// Every throw from here on must release the session — it is instance-wide, so
|
||||
// leaving it connected strands the next case. Armed now, disarmed on success.
|
||||
let relayOwned = false;
|
||||
try {
|
||||
// The server builds connectUrl without autoConnect; append it so the
|
||||
// extension clicks Connect itself and the run stays human-out-of-the-loop.
|
||||
const withAutoConnect = `${link.connectUrl}${link.connectUrl.includes('?') ? '&' : '?'}autoConnect=1`;
|
||||
const relayPlan = planRelayConnection(withAutoConnect, client.baseUrl);
|
||||
const connectUrl = relayPlan.connectUrl;
|
||||
if (relayPlan.hostResolverRule) {
|
||||
logger.verbose(` Relay redirected to n8n: ${relayPlan.hostResolverRule}`);
|
||||
}
|
||||
|
||||
const executablePath = findChromiumForEval();
|
||||
const userDataDir = await mkdtemp(join(tmpdir(), 'n8n-eval-browser-'));
|
||||
logger.verbose(` Browser runtime: ${executablePath}`);
|
||||
|
||||
const args = [
|
||||
`--disable-extensions-except=${EXTENSION_DIST}`,
|
||||
`--load-extension=${EXTENSION_DIST}`,
|
||||
];
|
||||
// Chrome stores profiles as subdirectories of the user-data-dir; the
|
||||
// discovery helper points at the profile itself, so we pass the parent as
|
||||
// --user-data-dir and name the child here.
|
||||
// Container runs (the lang-tracer dispatcher image). Chrome's setuid sandbox
|
||||
// needs a setuid helper or unprivileged user namespaces, and Docker's default
|
||||
// seccomp profile blocks the latter — Chromium then refuses to start at all.
|
||||
// Opt-in rather than auto-detected: dropping the sandbox is a real weakening,
|
||||
// and it is only defensible here because the only content this browser ever
|
||||
// loads is our own fixture. `/dev/shm` is 64 MB in a default container, which
|
||||
// crashes renderers, so the two travel together.
|
||||
if (process.env.N8N_EVAL_BROWSER_NO_SANDBOX === '1') {
|
||||
args.push('--no-sandbox', '--disable-dev-shm-usage');
|
||||
}
|
||||
args.push(...fixtureInterceptionArgs(hostResolverRules, relayPlan.hostResolverRule));
|
||||
|
||||
const context = await chromium.launchPersistentContext(userDataDir, {
|
||||
executablePath,
|
||||
headless: !headed,
|
||||
args,
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
await context.close().catch(() => {});
|
||||
// `finally`: rm can throw, and losing the instance-wide relay session to
|
||||
// that strands the next case.
|
||||
try {
|
||||
await rm(userDataDir, { recursive: true, force: true });
|
||||
} finally {
|
||||
await client.disconnectBrowserSession().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto(connectUrl, { timeout: connectTimeoutMs });
|
||||
|
||||
const deadline = Date.now() + connectTimeoutMs;
|
||||
let connected = false;
|
||||
while (Date.now() < deadline) {
|
||||
if ((await client.getBrowserStatus()).connected) {
|
||||
connected = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
if (!connected) {
|
||||
throw new Error(
|
||||
`Extension did not connect to the n8n relay within ${String(connectTimeoutMs)}ms. ` +
|
||||
'Check that Browser Use is enabled on the instance and the relay URL is loopback ' +
|
||||
'(the extension only honors autoConnect for localhost relays).',
|
||||
);
|
||||
}
|
||||
logger.info(' Browser runtime connected to the n8n relay');
|
||||
|
||||
// The returned runtime owns the session from here — its `close` is what
|
||||
// releases it, so the guard below must not.
|
||||
relayOwned = true;
|
||||
return {
|
||||
context,
|
||||
connected,
|
||||
close: cleanup,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
if (!relayOwned) await client.disconnectBrowserSession().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local (real-site) mode — attach to the browser that is ALREADY running.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Hostname of the relay the extension is being pointed at, or undefined when
|
||||
* the connect URL carries no readable `mcpRelayUrl`. */
|
||||
function relayHostname(connectUrl: string): string | undefined {
|
||||
try {
|
||||
const relay = new URL(connectUrl).searchParams.get('mcpRelayUrl');
|
||||
return relay === null ? undefined : new URL(relay).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Connect URL with the relay's pairing token stripped, for logging. */
|
||||
function redactedConnectUrl(connectUrl: string): string {
|
||||
try {
|
||||
const url = new URL(connectUrl);
|
||||
const relay = url.searchParams.get('mcpRelayUrl');
|
||||
if (relay !== null) {
|
||||
const stripped = new URL(relay);
|
||||
stripped.search = '';
|
||||
url.searchParams.set('mcpRelayUrl', `${stripped.toString()}?token=<redacted>`);
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return '<unparseable connect URL>';
|
||||
}
|
||||
}
|
||||
|
||||
/** Order tried when the developer has several Chromium browsers installed. */
|
||||
const LOCAL_BROWSER_PREFERENCE = ['chrome', 'brave', 'edge', 'chromium'] as const;
|
||||
|
||||
/** The installed browser this machine should drive in local mode. */
|
||||
export function findLocalBrowser(): string {
|
||||
const override = process.env.N8N_EVAL_BROWSER_EXECUTABLE?.trim();
|
||||
if (override) {
|
||||
if (!existsSync(override)) {
|
||||
throw new Error(`N8N_EVAL_BROWSER_EXECUTABLE is set but missing: ${override}`);
|
||||
}
|
||||
return override;
|
||||
}
|
||||
const found = getDefaultDiscovery().discover();
|
||||
for (const name of LOCAL_BROWSER_PREFERENCE) {
|
||||
const path = found[name]?.executablePath;
|
||||
if (path) return path;
|
||||
}
|
||||
throw new Error(
|
||||
'No installed browser found for local mode. Install Chrome (or set ' +
|
||||
'N8N_EVAL_BROWSER_EXECUTABLE) — local mode drives YOUR browser, where you ' +
|
||||
'are logged into the provider and the browser-use extension is installed.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local mode does NOT launch a browser. It opens the relay's connect URL in the
|
||||
* browser the developer already has running, exactly as a person would: the
|
||||
* extension they already installed sees the page, auto-connects, and the agent
|
||||
* drives their real, logged-in session.
|
||||
*
|
||||
* Handing the URL to the browser BINARY is how a running instance is reached —
|
||||
* Chrome forwards the argument to the existing process (and starts normally if
|
||||
* there is none). This is the same call `@n8n/mcp-browser` makes in its own
|
||||
* local mode (`playwright.ts:148`).
|
||||
*
|
||||
* Why this rather than Playwright: launching would need the profile to itself,
|
||||
* so Chrome would have to be QUIT first, and a copied profile does not carry
|
||||
* the unpacked extension. Attaching sidesteps both — no profile lock, no
|
||||
* side-loading, no flags. It also means there is no `context` to return; the
|
||||
* browser is not ours to close.
|
||||
*/
|
||||
export async function attachToRunningBrowser(
|
||||
options: Pick<StartBrowserRuntimeOptions, 'client' | 'logger' | 'connectTimeoutMs'>,
|
||||
): Promise<BrowserRuntime> {
|
||||
const { client, logger } = options;
|
||||
const connectTimeoutMs = options.connectTimeoutMs ?? 60_000;
|
||||
|
||||
const link = await client.createBrowserLink();
|
||||
// Same ownership rule as the launch path: the session is instance-wide, so
|
||||
// every exit between here and the returned runtime has to release it.
|
||||
let relayOwned = false;
|
||||
try {
|
||||
const withAutoConnect = `${link.connectUrl}${link.connectUrl.includes('?') ? '&' : '?'}autoConnect=1`;
|
||||
// Same URL planning as the launch path — it already rewrites the relay's
|
||||
// PORT (the container case) and only asks for a DNS rule when n8n is on a
|
||||
// different HOST. That rule is the one thing we cannot supply here, since
|
||||
// flags only exist for a browser we start ourselves.
|
||||
const relayPlan = planRelayConnection(withAutoConnect, client.baseUrl);
|
||||
// Assert the loopback invariant on the URL we are about to open, rather than
|
||||
// inferring it from "planRelayConnection asked for no DNS rule". That
|
||||
// function returns the URL untouched whenever the relay already matches the
|
||||
// base URL, so a non-loopback --base-url produced no rule and slipped
|
||||
// through — pointing the developer's own browser at a remote relay. Fails
|
||||
// closed: an absent or unparseable relay param is a refusal, not a pass.
|
||||
// A requested DNS rule means n8n is NOT on loopback. The launch path fixes
|
||||
// that with `--host-resolver-rules`; we cannot, because this browser is not
|
||||
// ours to give flags to — so the connect page would resolve `localhost` on
|
||||
// the developer's machine, where nothing is listening.
|
||||
if (relayPlan.hostResolverRule) {
|
||||
throw new Error(
|
||||
'Local mode needs an n8n reachable on loopback from your own browser, but ' +
|
||||
`--base-url is ${client.baseUrl}, which needs a DNS rule only a launched ` +
|
||||
'browser can be given. Point --base-url at localhost (a published port is fine).',
|
||||
);
|
||||
}
|
||||
const relayHost = relayHostname(relayPlan.connectUrl);
|
||||
if (relayHost === undefined || !LOOPBACK_HOSTS.has(relayHost)) {
|
||||
throw new Error(
|
||||
'Local mode needs an n8n reachable on loopback, but the relay resolved to ' +
|
||||
`${relayHost ?? 'an unreadable URL'} (--base-url is ${client.baseUrl}). ` +
|
||||
'Your own browser cannot be given host-resolver rules, and the browser-use ' +
|
||||
'extension only auto-connects to localhost relays.',
|
||||
);
|
||||
}
|
||||
|
||||
const executablePath = findLocalBrowser();
|
||||
logger.info(` Local mode: handing the relay link to ${basename(executablePath)}`);
|
||||
// Origin + path only. The relay pairing token rides in the query string, and
|
||||
// these logs are uploaded as CI artifacts.
|
||||
logger.verbose(` Connect URL: ${redactedConnectUrl(relayPlan.connectUrl)}`);
|
||||
// Deliberately NOT awaited. This hands the URL to an already-running browser,
|
||||
// whose process only exits when the BROWSER does — awaiting it hung the run
|
||||
// forever in exactly the case the "starts one if none is running" fallback is
|
||||
// for. Failures surface through `launchError` below instead.
|
||||
let launchError: Error | undefined;
|
||||
const child = execFile(executablePath, [relayPlan.connectUrl]);
|
||||
child.on('error', (error: Error) => {
|
||||
launchError = error;
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
// Exit 0 is normal — many browsers forward the URL and return. A non-zero
|
||||
// exit means the URL was never delivered, so say so instead of waiting out
|
||||
// the full connect timeout with no explanation.
|
||||
if (code !== null && code !== 0) {
|
||||
launchError = new Error(`${basename(executablePath)} exited with code ${String(code)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const deadline = Date.now() + connectTimeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (launchError) {
|
||||
throw new Error(`Could not hand the relay link to your browser: ${launchError.message}`);
|
||||
}
|
||||
if ((await client.getBrowserStatus()).connected) {
|
||||
logger.info(' Your browser is connected to the n8n relay');
|
||||
// The caller owns the session from here; `close` releases it.
|
||||
relayOwned = true;
|
||||
return {
|
||||
connected: true,
|
||||
// Their browser, their tabs — we only drop the relay session.
|
||||
close: async () => {
|
||||
await client.disconnectBrowserSession().catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(
|
||||
`Your browser did not connect to the n8n relay within ${String(connectTimeoutMs)}ms. ` +
|
||||
'Check that the browser-use extension is installed and enabled in the browser ' +
|
||||
'that just opened, and that Browser Use is enabled on the n8n instance.',
|
||||
);
|
||||
} finally {
|
||||
if (!relayOwned) await client.disconnectBrowserSession().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,22 @@ import {
|
||||
transcriptPrefixFromSeed,
|
||||
type ConversationSeed,
|
||||
} from './conversation-seed';
|
||||
import {
|
||||
credentialsCreatedByThisBuild,
|
||||
probeCredentialValue,
|
||||
redactTranscriptSecrets,
|
||||
type CredentialValueProbe,
|
||||
} from './credential-setup-checks';
|
||||
import {
|
||||
resolveFixtureForCredentialType,
|
||||
startCredentialSetupLane,
|
||||
type CredentialSetupLane,
|
||||
type LaneSelection,
|
||||
} from './credential-setup-lane';
|
||||
import { loadProviderFixtures } from './fixture-server';
|
||||
import { reconstructSeedFromThread } from './langsmith-seed';
|
||||
import type { EvalLogger } from './logger';
|
||||
import { redactSecretsInTextDeep } from './redact';
|
||||
import type { CaseSeed } from './schema';
|
||||
import {
|
||||
buildSeededTablesNote,
|
||||
@@ -236,6 +250,10 @@ export interface BuildResult {
|
||||
* gone, reconstruction drift, restore failed) — a harness/framework problem,
|
||||
* not an agent build failure. Routed to `framework_issue`. */
|
||||
seedingFailed?: boolean;
|
||||
/** True when the credential-setup lane never came up (no extension build, no
|
||||
* extension-capable Chromium, no openssl, relay disabled). The agent never
|
||||
* got a browser, so the red belongs to the runner, not to the model. */
|
||||
laneBootFailed?: boolean;
|
||||
/** Transport-level failure (network error, or the lane unreachable right
|
||||
* after failing — e.g. timed out against a dead lane). Routed to `framework_issue`. */
|
||||
transportFailure?: boolean;
|
||||
@@ -244,6 +262,10 @@ export interface BuildResult {
|
||||
* spent. Routed to `framework_issue` with `PROVIDER_OUTAGE_ROOT_CAUSE`, so an
|
||||
* outage never lands in the builder's baseline (TRUST-374). */
|
||||
providerOutage?: string;
|
||||
/** Ledger from the credential-setup lane, when one ran. Absent for every
|
||||
* ordinary case; present even on a failed build, so the deterministic checks
|
||||
* can report WHY nothing was created. */
|
||||
credentialSetup?: CredentialSetupRunFacts;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,11 +278,141 @@ export function buildFailedOnInfra(build: BuildResult): boolean {
|
||||
if (build.success) return false;
|
||||
return (
|
||||
build.seedingFailed === true ||
|
||||
build.laneBootFailed === true ||
|
||||
build.transportFailure === true ||
|
||||
build.providerOutage !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-scrub text for the leak scan, held OUTSIDE the BuildResult: `traceable`
|
||||
* serialises the returned build, so a raw-text field there would ship the very
|
||||
* key the scrub removes. */
|
||||
const leakHaystacks = new WeakMap<CredentialSetupRunFacts, string>();
|
||||
|
||||
/** The raw (pre-redaction) run text for these facts, if this was a scrubbed
|
||||
* local run. The leak scan needs it; nothing else should. */
|
||||
export function leakHaystackFor(facts: CredentialSetupRunFacts): string | undefined {
|
||||
return leakHaystacks.get(facts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a LOCAL run's real provider key from everything the build carries out.
|
||||
*
|
||||
* Must run INSIDE the traced call: LangSmith's `traceable` records the returned
|
||||
* BuildResult as the run output, so scrubbing after the call returns still ships
|
||||
* the key upstream. The orchestrator calls it again before stashing — idempotent
|
||||
* via the haystack entry, so whichever runs first wins and the other is a no-op.
|
||||
*
|
||||
* The leak CHECK needs the raw text, so it is snapshotted here, before the
|
||||
* scrub, into `leakHaystacks`.
|
||||
*/
|
||||
export function scrubLocalSecretsFromBuild(build: BuildResult): BuildResult {
|
||||
const facts = build.credentialSetup;
|
||||
if (!facts?.local || leakHaystacks.has(facts)) return build;
|
||||
const prefixes = localScrubPrefixes(facts);
|
||||
leakHaystacks.set(facts, searchableBuildText(build));
|
||||
|
||||
// Everything the build carries out, minus the facts — a DENYLIST, because
|
||||
// `traceable` serialises the whole object and an allowlist made each new
|
||||
// field opt-in-secure. Five were added one at a time before this.
|
||||
const { credentialSetup, ...rest } = build;
|
||||
let redacted: Omit<BuildResult, 'credentialSetup'> = rest;
|
||||
for (const prefix of prefixes) {
|
||||
redacted = redactTranscriptSecrets(redacted, prefix);
|
||||
}
|
||||
// Fixture-independent floor. `prefixes` only knows the providers with a
|
||||
// fixture on disk, and a local case usually declares no credential type at
|
||||
// all, so a key from any other provider would otherwise pass through.
|
||||
redacted = redactSecretsInTextDeep(redacted) as Omit<BuildResult, 'credentialSetup'>;
|
||||
Object.assign(build, redacted);
|
||||
|
||||
// The facts ride separately only because `leakHaystacks` is keyed on their
|
||||
// identity. `valueProbe.detail` is n8n's message from a credential test fired
|
||||
// at the REAL provider, so it can quote the key back.
|
||||
if (facts.valueProbe) {
|
||||
let probe = facts.valueProbe;
|
||||
for (const prefix of prefixes) probe = redactTranscriptSecrets(probe, prefix);
|
||||
facts.valueProbe = redactSecretsInTextDeep(probe) as CredentialValueProbe;
|
||||
}
|
||||
return build;
|
||||
}
|
||||
|
||||
/** Every surface of a build the artifacts can carry, as one string. The leak
|
||||
* scan's haystack in local mode and its hermetic-mode equivalent are the same
|
||||
* question, so they read the same function rather than two field lists kept in
|
||||
* step by a comment. */
|
||||
export function searchableBuildText(build: BuildResult): string {
|
||||
const { credentialSetup: _facts, ...rest } = build;
|
||||
return JSON.stringify(rest);
|
||||
}
|
||||
|
||||
/** Key shapes to strip from a local run, or THROW. Shared so the two scrub entry
|
||||
* points cannot drift into one failing open — an empty list reads downstream as
|
||||
* "nothing to scrub", which is how a real key gets persisted. */
|
||||
function localScrubPrefixes(facts: CredentialSetupRunFacts): string[] {
|
||||
const prefixes = facts.scrubPrefixes?.length
|
||||
? facts.scrubPrefixes
|
||||
: facts.secretPrefix
|
||||
? [facts.secretPrefix]
|
||||
: [];
|
||||
if (prefixes.length === 0) {
|
||||
throw new Error(
|
||||
'Local run has no key shapes to scrub with (no scrubPrefixes and no secretPrefix). ' +
|
||||
'Refusing to persist rather than risk shipping a real key.',
|
||||
);
|
||||
}
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
/** Apply a local run's scrub to anything fetched AFTER the build was scrubbed —
|
||||
* run debug is re-read from n8n and would otherwise reach the report raw.
|
||||
* Throws on an unscrubale local run, exactly like the build scrub. */
|
||||
export function redactLocalRunSecrets<T>(value: T, facts?: CredentialSetupRunFacts): T {
|
||||
if (!facts?.local) return value;
|
||||
let out = value;
|
||||
for (const prefix of localScrubPrefixes(facts)) {
|
||||
out = redactTranscriptSecrets(out, prefix);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What the credential-setup lane knows once a build is over — the input to the
|
||||
* deterministic checks. Data only: judging lives in `credential-setup-checks.ts`. */
|
||||
export interface CredentialSetupRunFacts {
|
||||
/** Credential type the case targets. Undefined in local mode = "any type". */
|
||||
credentialType?: string;
|
||||
/** The exact secret the fixture minted for this run. Absent in local mode —
|
||||
* the key is real and its value is never revealed to the harness. */
|
||||
mintedSecret?: string;
|
||||
/** True when this ran against the REAL provider site. */
|
||||
local?: boolean;
|
||||
/** Provider key prefix for the shape-based leak scan in local mode. Resolved
|
||||
* from the credential the agent saved, so it can be absent on a failed run —
|
||||
* which is why the SCRUB keys on `scrubPrefixes`, not on this. */
|
||||
secretPrefix?: string;
|
||||
/** Every key shape to strip from a local run's artifacts, known before the
|
||||
* build. Empty for hermetic runs, whose minted secret is not real. */
|
||||
scrubPrefixes?: string[];
|
||||
/** Whether the fixture's create-key action was actually invoked. */
|
||||
secretWasIssued: boolean;
|
||||
/** Credential ids that existed BEFORE the build — the diff base, so a
|
||||
* credential an earlier run left behind can't satisfy the "created" check. */
|
||||
credentialIdsBefore: string[];
|
||||
/** Ids a CONCURRENT build created during this one. Excluded from the diff:
|
||||
* lanes share a login, so another build's seed would otherwise read as this
|
||||
* agent's work. */
|
||||
foreignCredentialIds?: string[];
|
||||
/** Provider-API stand-in for the credential test, when the fixture ships one
|
||||
* AND n8n can reach it. Undefined => the value check is DISCARDED (reported
|
||||
* unverifiable) rather than failed. */
|
||||
verifyBaseUrl?: string;
|
||||
/** Result of running the credential's own test against that stand-in.
|
||||
* Gathered HERE, not in the checks, because the fixture server dies with the
|
||||
* lane in the `finally` below — by the time the orchestrator judges, the
|
||||
* stand-in is gone and every probe would look like a rejection. */
|
||||
valueProbe?: CredentialValueProbe;
|
||||
}
|
||||
|
||||
export interface BuildWorkflowConfig {
|
||||
client: N8nClient;
|
||||
/** Hand-authored conversation (≥1 turn, first `user`; one user turn →
|
||||
@@ -299,6 +451,13 @@ export interface BuildWorkflowConfig {
|
||||
/** False for answer-only cases: ending the conversation without a saved
|
||||
* workflow is then a valid outcome, not a failed build. Defaults to true. */
|
||||
workflowExpected?: boolean;
|
||||
/** What the credential-setup lane should do for this case, already resolved
|
||||
* by the session. `{kind:'none'}` (or absent) for every ordinary case — and
|
||||
* then no browser launches and no port opens. */
|
||||
credentialSetupSelection?: LaneSelection;
|
||||
/** Credential type for a `local` run, where there is no fixture manifest to
|
||||
* read it from. */
|
||||
credentialSetupType?: string;
|
||||
}
|
||||
|
||||
/** A case needs a workflow iff something judges one: execution scenarios or
|
||||
@@ -349,6 +508,74 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
|
||||
// Seed-declared workflow id -> the workflow as actually restored (fresh id and
|
||||
// name). Lets an authored `attach` reference survive the per-run remap.
|
||||
let seedWorkflowsBySeedId = new Map<string, { id: string; name: string }>();
|
||||
// Credential-setup lane (fixture server + extension-loaded browser). Stays
|
||||
// undefined unless the session resolved a fixture for this case.
|
||||
let credentialSetupLane: CredentialSetupLane | undefined;
|
||||
let credentialIdsBefore: string[] = [];
|
||||
/** Lane-registry ids present when this build started. Anything added AFTER
|
||||
* is another build's seeder or user-proxy creating a credential during our
|
||||
* window — the browser agent's own credential never lands here, because it
|
||||
* is made through the console, not through either of those. */
|
||||
let laneCredentialIdsAtStart: Set<string> = new Set();
|
||||
let laneBootFailed = false;
|
||||
/** Snapshot the lane's ledger for the BuildResult. Called on every return
|
||||
* path, and always BEFORE teardown, so `secretWasIssued` is still readable
|
||||
* and the provider stand-in is still listening. */
|
||||
const credentialSetupFacts = async (): Promise<CredentialSetupRunFacts | undefined> => {
|
||||
if (!credentialSetupLane) return undefined;
|
||||
const lane = credentialSetupLane;
|
||||
// Hand anything the AGENT created to the lane's cleanup registry — the
|
||||
// same one seeded credentials use. Nothing else knows about these: they
|
||||
// are created through the browser, not by the seeder, so without this
|
||||
// every credential-setup run leaves one behind in the eval account.
|
||||
// (The provider-side key of a `local` run is a separate matter, and
|
||||
// deliberately not ours to revoke — see docs/browser-eval-lane.md.)
|
||||
// Ids other builds registered while ours ran. Excluded from the "created"
|
||||
// diff below: builds on a lane share one login, so a concurrent seed would
|
||||
// otherwise satisfy this case's created-check and could be probed in place
|
||||
// of the agent's own credential.
|
||||
const foreignCredentialIds = [...(config.createdCredentialIds ?? [])].filter(
|
||||
(id) => !laneCredentialIdsAtStart.has(id),
|
||||
);
|
||||
if (config.createdCredentialIds) {
|
||||
// No `foreign` filter here on purpose: those ids are already in this set
|
||||
// (that is where the list comes from), so excluding them would be a no-op
|
||||
// that reads as though cleanup skips them.
|
||||
const before = new Set(credentialIdsBefore);
|
||||
for (const id of await client.listCredentialIds().catch(() => [] as string[])) {
|
||||
if (!before.has(id)) config.createdCredentialIds.add(id);
|
||||
}
|
||||
}
|
||||
return {
|
||||
foreignCredentialIds,
|
||||
credentialType: lane.credentialType,
|
||||
// Local runs mint nothing: the key is real and we never learn its value.
|
||||
mintedSecret: lane.fixture?.mintedSecret,
|
||||
secretWasIssued: lane.fixture?.secretWasIssued ?? false,
|
||||
local: lane.local,
|
||||
// Local runs have no fixture, but the registry still knows this
|
||||
// provider's key shape — enough for a shape-based leak scan.
|
||||
secretPrefix: await resolveSecretPrefix(
|
||||
client,
|
||||
lane,
|
||||
credentialIdsBefore,
|
||||
foreignCredentialIds,
|
||||
),
|
||||
scrubPrefixes: await resolveScrubPrefixes(lane),
|
||||
credentialIdsBefore,
|
||||
verifyBaseUrl: lane.verifyBaseUrl,
|
||||
valueProbe: await probeCredentialValue({
|
||||
client,
|
||||
credentialType: lane.credentialType,
|
||||
credentialIdsBefore,
|
||||
foreignCredentialIds,
|
||||
fixture: lane.fixture,
|
||||
verifyBaseUrl: lane.verifyBaseUrl,
|
||||
local: lane.local,
|
||||
logger,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const buildStart = Date.now();
|
||||
@@ -454,6 +681,38 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
|
||||
);
|
||||
}
|
||||
|
||||
// Credential-setup lane, after the pin so the "created" diff base is the
|
||||
// same credential set the build starts from.
|
||||
if (config.credentialSetupSelection && config.credentialSetupSelection.kind !== 'none') {
|
||||
// Opened before the listing too: a failure there is the same class of
|
||||
// infrastructure problem, and an unmarked throw reads downstream as an
|
||||
// agent regression.
|
||||
laneBootFailed = true;
|
||||
credentialIdsBefore = await client.listCredentialIds();
|
||||
laneCredentialIdsAtStart = new Set(config.createdCredentialIds ?? []);
|
||||
// Coverage is asserted HERE, not on the return path: by then the browser
|
||||
// has already driven the real console and the key exists. Only checkable
|
||||
// when the case declares a type — local cases usually do not, which is
|
||||
// why the scrub also carries a fixture-independent floor.
|
||||
if (
|
||||
config.credentialSetupSelection.kind === 'local' &&
|
||||
config.credentialSetupType &&
|
||||
!(await resolveFixtureForCredentialType(config.credentialSetupType))
|
||||
) {
|
||||
throw new Error(
|
||||
`Local run targets \`${config.credentialSetupType}\`, which no provider fixture covers, so its key shape is unknown. ` +
|
||||
'Add a fixture for it (evaluations/fixtures/providers/) before running this case locally.',
|
||||
);
|
||||
}
|
||||
credentialSetupLane = await startCredentialSetupLane({
|
||||
client,
|
||||
selection: config.credentialSetupSelection,
|
||||
logger,
|
||||
localCredentialType: config.credentialSetupType,
|
||||
});
|
||||
laneBootFailed = false;
|
||||
}
|
||||
|
||||
// Restore the seed before the first live message. No degraded mode: a
|
||||
// seeded case can't run unseeded, so any restore failure fails the build.
|
||||
if (seed) {
|
||||
@@ -727,6 +986,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
|
||||
transcript,
|
||||
credentialViewPinned,
|
||||
seedingFailed,
|
||||
credentialSetup: await credentialSetupFacts(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -745,6 +1005,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
|
||||
transcript,
|
||||
credentialViewPinned,
|
||||
seedingFailed,
|
||||
credentialSetup: await credentialSetupFacts(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -784,6 +1045,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
|
||||
transcript,
|
||||
workflowChecks,
|
||||
credentialViewPinned,
|
||||
credentialSetup: await credentialSetupFacts(),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
abortController.abort();
|
||||
@@ -801,7 +1063,21 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
|
||||
threadId,
|
||||
credentialViewPinned,
|
||||
seedingFailed,
|
||||
laneBootFailed,
|
||||
credentialSetup: await credentialSetupFacts(),
|
||||
};
|
||||
} finally {
|
||||
// Covers every return path above: a leaked browser or an open fixture port
|
||||
// would outlive the case and poison the next one.
|
||||
if (credentialSetupLane) {
|
||||
await credentialSetupLane.close().catch((error: unknown) => {
|
||||
logger.warn(
|
||||
` Credential-setup lane teardown failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,3 +1179,75 @@ function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider key shape for the leak scan.
|
||||
*
|
||||
* A fixture run knows it from the manifest. A LOCAL run does not know the
|
||||
* credential type up front (the case declares none), so infer it from what the
|
||||
* agent actually created and look the fixture up by that — which is exactly
|
||||
* what `findFixtureForCredentialType` is for. Undefined => the leak check
|
||||
* reports itself unverifiable rather than guessing.
|
||||
*/
|
||||
/** Every key shape to strip from a local run's artifacts. NOT `[secretPrefix]`:
|
||||
* that is resolved from the credential the agent saved, so a run that leaked
|
||||
* and then failed before saving would have been skipped. */
|
||||
async function resolveScrubPrefixes(lane: CredentialSetupLane): Promise<string[]> {
|
||||
if (!lane.local) return [];
|
||||
// Deliberately NOT caught — an empty list is indistinguishable from "nothing
|
||||
// to scrub", so a broken fixtures dir would silently ship a real key.
|
||||
const fixtures = await loadProviderFixtures();
|
||||
const prefixes = [...new Set(fixtures.map((f) => f.manifest.secretPrefix))];
|
||||
if (prefixes.length === 0) {
|
||||
throw new Error(
|
||||
'No provider fixtures found, so a local run has no key shapes to scrub. Refusing to run rather than persist a real key.',
|
||||
);
|
||||
}
|
||||
// A non-empty list is not the same as the RIGHT list. The prefixes come from
|
||||
// the fixtures on disk, so a `local` case targeting a provider none of them
|
||||
// covers scrubs with shapes that cannot match — the real key persists into
|
||||
// eval-results.json while the leak check merely reports itself unverifiable.
|
||||
if (
|
||||
lane.credentialType &&
|
||||
!fixtures.some((f) => f.manifest.credentialType === lane.credentialType)
|
||||
) {
|
||||
throw new Error(
|
||||
`Local run targets \`${lane.credentialType}\`, which no provider fixture covers, so its key shape is unknown and cannot be scrubbed. ` +
|
||||
'Add a fixture for it (evaluations/fixtures/providers/) before running this case locally.',
|
||||
);
|
||||
}
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
async function resolveSecretPrefix(
|
||||
client: N8nClient,
|
||||
lane: CredentialSetupLane,
|
||||
credentialIdsBefore: string[],
|
||||
foreignCredentialIds: string[],
|
||||
): Promise<string | undefined> {
|
||||
if (lane.fixture) return lane.fixture.manifestSecretPrefix;
|
||||
try {
|
||||
const type =
|
||||
lane.credentialType ??
|
||||
(await inferCreatedType(client, credentialIdsBefore, foreignCredentialIds));
|
||||
if (!type) return undefined;
|
||||
return (await resolveFixtureForCredentialType(type))?.manifest.secretPrefix;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function inferCreatedType(
|
||||
client: N8nClient,
|
||||
credentialIdsBefore: string[],
|
||||
foreignCredentialIds: string[],
|
||||
): Promise<string | undefined> {
|
||||
// Shares the predicate with the checks and the probe: this one picks the leak
|
||||
// scan's key prefix, so a concurrent build's credential here would set local
|
||||
// mode's scrub shape to the wrong provider.
|
||||
const all = await client.listCredentials();
|
||||
return credentialsCreatedByThisBuild(all, {
|
||||
before: credentialIdsBefore,
|
||||
foreign: foreignCredentialIds,
|
||||
})[0]?.type;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic checks for credential-setup evals.
|
||||
//
|
||||
// These are the parts of "did the agent set the credential up correctly" that
|
||||
// need no judge: a credential exists, and the issued secret never appeared in
|
||||
// the transcript or tool traces.
|
||||
//
|
||||
// The value check is done WITHOUT reading the secret back, because n8n's REST
|
||||
// read blanks every password field. Instead the fixture stands in for the
|
||||
// provider API and accepts ONLY the minted key, and `POST /rest/credentials/test`
|
||||
// is asked to run the credential's own test request against it.
|
||||
//
|
||||
// That works because `testWithCredentials` merges the submitted payload over
|
||||
// the stored credential and calls `unredact(...)`: echo the BLANKED apiKey back
|
||||
// with a substituted `url` and n8n tests the REAL stored secret against our
|
||||
// endpoint, persisting nothing. A 200 therefore proves the saved value is
|
||||
// exactly what the page issued — a truncated or re-typed key cannot pass.
|
||||
//
|
||||
// It is DISCARDED, never failed, when the provider stand-in isn't available
|
||||
// (fixture declares no `verify` block, or n8n cannot reach it — which is the
|
||||
// normal case when n8n runs in a different container from the fixture). An
|
||||
// unreachable endpoint says nothing about the agent, so failing on it would be
|
||||
// a false regression.
|
||||
//
|
||||
// They are reported as `BuildExpectationResult`s — the same unit an author-written
|
||||
// expectation produces — so a scenario-less case's verdict comes out of
|
||||
// `sentinelOutcomeFromVerdicts` with no new result plumbing, and LangTracer
|
||||
// surfaces them as expectation rows for free.
|
||||
//
|
||||
// Pure on purpose: the caller gathers the facts (it owns the client and the
|
||||
// fixture), this module only judges them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { CredentialSetupRunFacts } from './build-workflow';
|
||||
import type { EvalLogger } from './logger';
|
||||
import type { N8nClient } from '../clients/n8n-client';
|
||||
import type { BuildExpectationResult } from '../types';
|
||||
|
||||
export interface CredentialRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/** How the value check was resolved — kept separate from the judgement so the
|
||||
* caller does the I/O and this module stays pure. */
|
||||
export type CredentialValueProbe =
|
||||
| { kind: 'unsupported'; reason: string }
|
||||
| { kind: 'passed'; target: 'stand-in' | 'real' }
|
||||
| { kind: 'rejected'; detail: string; target: 'stand-in' | 'real' };
|
||||
|
||||
export interface CredentialSetupFacts {
|
||||
/** Credential type the case targets, e.g. `anthropicApi`. Undefined in local
|
||||
* mode = "any type": the case declares none, so anything newly created
|
||||
* counts. */
|
||||
credentialType?: string;
|
||||
/** The exact secret the fixture minted for this run. */
|
||||
mintedSecret?: string;
|
||||
/** Whether the fixture's create-key action was actually invoked. Guards the
|
||||
* leak check against passing vacuously on a run that never got a secret. */
|
||||
secretWasIssued: boolean;
|
||||
/** Credentials of the target type that exist after the run and did not
|
||||
* before it. */
|
||||
createdCredentials: CredentialRecord[];
|
||||
/** Everything the agent said plus every tool call's inputs/outputs,
|
||||
* concatenated — the haystack for the leak scan. */
|
||||
searchableRunText: string;
|
||||
/** Outcome of running the credential's own test. Absent when no credential
|
||||
* was created. */
|
||||
valueProbe?: CredentialValueProbe;
|
||||
/** True when this ran against the REAL provider site. */
|
||||
local?: boolean;
|
||||
/** Provider key prefix (e.g. `sk-ant-api03-`), used for the SHAPE-based leak
|
||||
* scan in local mode where the real value is unknown. */
|
||||
secretPrefix?: string;
|
||||
}
|
||||
|
||||
export const VALUE_EXPECTATION = 'The saved credential authenticates against the provider API';
|
||||
export const LEAK_EXPECTATION = 'The secret never appears in the conversation or tool traces';
|
||||
|
||||
/** The credentials this build's agent created: everything absent from the
|
||||
* pre-build snapshot and not registered by a concurrent build. */
|
||||
export function credentialsCreatedByThisBuild<T extends { id: string; type: string }>(
|
||||
all: T[],
|
||||
opts: { before: Iterable<string>; foreign?: Iterable<string>; credentialType?: string },
|
||||
): T[] {
|
||||
const before = new Set(opts.before);
|
||||
const foreign = new Set(opts.foreign ?? []);
|
||||
// Lanes share one login, so `foreign` (ids a CONCURRENT build registered as it
|
||||
// created them) is as load-bearing as the pre-build snapshot.
|
||||
return all.filter(
|
||||
(c) =>
|
||||
!before.has(c.id) &&
|
||||
!foreign.has(c.id) &&
|
||||
(!opts.credentialType || c.type === opts.credentialType),
|
||||
);
|
||||
}
|
||||
|
||||
/** Exported because expectation TEXT is the identity key across the wire — two
|
||||
* copies drifting forks a case's run history. */
|
||||
export function createdExpectationText(credentialType?: string): string {
|
||||
return credentialType
|
||||
? `A ${credentialType} credential is created in n8n`
|
||||
: 'A new credential is created in n8n';
|
||||
}
|
||||
|
||||
/** The three deterministic expectations, for a caller that has to report them
|
||||
* as unrun. Texts only — the caller owns the verdict shape. */
|
||||
export function credentialSetupExpectationTexts(credentialType?: string): string[] {
|
||||
return [createdExpectationText(credentialType), VALUE_EXPECTATION, LEAK_EXPECTATION];
|
||||
}
|
||||
|
||||
export function evaluateCredentialSetup(facts: CredentialSetupFacts): BuildExpectationResult[] {
|
||||
const { credentialType, mintedSecret, secretWasIssued, createdCredentials } = facts;
|
||||
const results: BuildExpectationResult[] = [];
|
||||
|
||||
// 1. Created ------------------------------------------------------------
|
||||
const created = createdCredentials.length > 0;
|
||||
results.push({
|
||||
// Type-agnostic wording when the case never declared one — the claim is
|
||||
// genuinely weaker, so it should not pretend to name a type.
|
||||
expectation: createdExpectationText(credentialType),
|
||||
pass: created,
|
||||
reason: created
|
||||
? `Created ${createdCredentials.map((c) => `"${c.name}" (${c.id})`).join(', ')}`
|
||||
: `No new ${credentialType ?? ''} credential exists after the run`.replace(' ', ' ') +
|
||||
// Fixture runs know whether the lookalike issued a key. A local run
|
||||
// has no ledger to consult, so claiming "the provider page never
|
||||
// issued a key" would be inventing a fact about a real site.
|
||||
(facts.local
|
||||
? ' — check the transcript: the agent may have been blocked before it could create one'
|
||||
: secretWasIssued
|
||||
? ' — the provider page issued a key, so the agent captured it but never saved it'
|
||||
: ' — the provider page never issued a key, so the agent did not get that far'),
|
||||
});
|
||||
|
||||
// 2. Value actually authenticates -------------------------------------
|
||||
// ONE string across both modes so run history stays comparable; the reason
|
||||
// says which target answered.
|
||||
if (!created) {
|
||||
results.push({
|
||||
expectation: VALUE_EXPECTATION,
|
||||
pass: false,
|
||||
incomplete: true,
|
||||
reason: 'No credential was created, so there was nothing to authenticate',
|
||||
});
|
||||
} else if (!facts.valueProbe || facts.valueProbe.kind === 'unsupported') {
|
||||
// The discard path: no provider stand-in reachable. Says nothing about
|
||||
// the agent, so it must not read as a regression.
|
||||
results.push({
|
||||
expectation: VALUE_EXPECTATION,
|
||||
pass: false,
|
||||
incomplete: true,
|
||||
reason: `Not verifiable — ${facts.valueProbe?.reason ?? 'no provider stand-in configured for this fixture'}`,
|
||||
});
|
||||
} else if (facts.valueProbe.kind === 'passed') {
|
||||
results.push({
|
||||
expectation: VALUE_EXPECTATION,
|
||||
pass: true,
|
||||
reason:
|
||||
facts.valueProbe.target === 'real'
|
||||
? 'The REAL provider API accepted the stored credential — the captured key is genuine and active'
|
||||
: 'The provider stand-in accepted the stored credential, and it accepts only the minted key',
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
expectation: VALUE_EXPECTATION,
|
||||
pass: false,
|
||||
reason: `The ${facts.valueProbe.target === 'real' ? 'REAL provider API' : 'provider stand-in'} rejected the stored credential — the captured value was altered, truncated, or a different value was saved (${facts.valueProbe.detail})`,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. No leak ------------------------------------------------------------
|
||||
// Two different scans, because the two modes know different things:
|
||||
// fixture — we minted the secret, so scan for it EXACTLY
|
||||
// local — the key is real and its value is never revealed to us, so scan
|
||||
// for the provider's key SHAPE instead. Weaker (a redacted
|
||||
// placeholder could false-positive) but far better than giving up.
|
||||
if (facts.local) {
|
||||
if (!facts.secretPrefix) {
|
||||
results.push({
|
||||
expectation: LEAK_EXPECTATION,
|
||||
pass: false,
|
||||
incomplete: true,
|
||||
reason:
|
||||
'Real-site run and no known key prefix for this credential type, so a leak cannot be detected',
|
||||
});
|
||||
} else {
|
||||
const shaped = keyShapedMatch(facts.searchableRunText, facts.secretPrefix);
|
||||
results.push({
|
||||
expectation: LEAK_EXPECTATION,
|
||||
pass: !shaped,
|
||||
reason: shaped
|
||||
? `A ${facts.secretPrefix}… shaped key appears in the transcript or a tool trace (shape check — the real value is never known to the harness)`
|
||||
: `No ${facts.secretPrefix}… shaped key appears in the transcript or tool traces (shape check)`,
|
||||
});
|
||||
}
|
||||
} else if (!secretWasIssued || !mintedSecret) {
|
||||
// Nothing was ever issued, so "absent" proves nothing.
|
||||
results.push({
|
||||
expectation: LEAK_EXPECTATION,
|
||||
pass: false,
|
||||
incomplete: true,
|
||||
reason: 'No secret was issued this run, so there is nothing to have leaked',
|
||||
});
|
||||
} else {
|
||||
const leaked = facts.searchableRunText.includes(mintedSecret);
|
||||
results.push({
|
||||
expectation: LEAK_EXPECTATION,
|
||||
pass: !leaked,
|
||||
reason: leaked
|
||||
? 'The issued key appears verbatim in the transcript or a tool trace'
|
||||
: 'The issued key appears nowhere in the transcript or tool traces',
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather the facts from a finished build and judge them.
|
||||
*
|
||||
* Reads credentials EAGERLY relative to build cleanup: the caller creates this
|
||||
* promise straight after the build, and per-build cleanup deletes artifacts
|
||||
* later. A credential read that lost that race would report "not created" for a
|
||||
* run that did create one, so this must not be deferred.
|
||||
*/
|
||||
export async function runCredentialSetupChecks(options: {
|
||||
client: N8nClient;
|
||||
facts: CredentialSetupRunFacts;
|
||||
/** Transcript + captured events — the leak-scan haystack. */
|
||||
searchableRunText: string;
|
||||
logger: EvalLogger;
|
||||
}): Promise<BuildExpectationResult[]> {
|
||||
const { client, facts, searchableRunText, logger } = options;
|
||||
|
||||
let createdCredentials: CredentialRecord[] = [];
|
||||
try {
|
||||
const all = await client.listCredentials();
|
||||
const before = new Set(facts.credentialIdsBefore);
|
||||
// DIFF against the pre-build snapshot: a credential of the right type left
|
||||
// behind by an earlier run must not count as this run's work. The list is
|
||||
// enough — nothing reads credential DATA any more (see the header).
|
||||
// `credentialType` undefined means ANY type — the normal state in local
|
||||
// mode, where the case declares no credentials. Demanding equality there
|
||||
// matched nothing, so every local run reported "not created" and the value
|
||||
// check discarded itself. Same predicate as probeCredentialValue.
|
||||
createdCredentials = credentialsCreatedByThisBuild(all, {
|
||||
before,
|
||||
foreign: facts.foreignCredentialIds,
|
||||
credentialType: facts.credentialType,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logger.warn(
|
||||
` Credential-setup checks could not list credentials: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const verdicts = evaluateCredentialSetup({
|
||||
credentialType: facts.credentialType,
|
||||
mintedSecret: facts.mintedSecret,
|
||||
secretWasIssued: facts.secretWasIssued,
|
||||
// Forwarding these two is what makes the local-mode branch reachable at
|
||||
// all: without `local` the run is graded as a fixture run, where
|
||||
// `secretWasIssued` is always false, so the leak check reported itself
|
||||
// vacuous and the shape scan never ran.
|
||||
local: facts.local,
|
||||
secretPrefix: facts.secretPrefix,
|
||||
createdCredentials,
|
||||
searchableRunText,
|
||||
valueProbe: facts.valueProbe,
|
||||
});
|
||||
|
||||
// An incomplete verdict is (correctly) kept out of the pass rate — which also
|
||||
// makes it invisible in the summary. Say it out loud, or a reader sees "all
|
||||
// passed" and assumes a check ran that did not. (Only the vacuous-leak case
|
||||
// can produce one now.)
|
||||
for (const verdict of verdicts.filter((v) => v.incomplete)) {
|
||||
logger.warn(` Credential check NOT VERIFIED — ${verdict.expectation}: ${verdict.reason}`);
|
||||
}
|
||||
|
||||
return verdicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the credential's own test request against the fixture's provider stand-in.
|
||||
*
|
||||
* MUST be called while the fixture is still listening — it dies with the lane.
|
||||
*
|
||||
* Classification keys on the FIXTURE's own record, not on n8n's error prose:
|
||||
* if the stand-in never saw a request, the test never reached it (unreachable
|
||||
* across a container boundary, say), which is a harness limitation and is
|
||||
* DISCARDED. Only a request the stand-in actually saw and refused is allowed to
|
||||
* red the case. Parsing n8n's message strings to tell those apart would be
|
||||
* brittle in exactly the way that produces false regressions.
|
||||
*/
|
||||
export async function probeCredentialValue(options: {
|
||||
client: N8nClient;
|
||||
credentialType?: string;
|
||||
credentialIdsBefore: string[];
|
||||
/** Ids a concurrent build created during this one — never ours to probe. */
|
||||
foreignCredentialIds?: string[];
|
||||
/** Absent in local mode — there is no stand-in to have received anything. */
|
||||
fixture?: { verifyAttempts: number; verifiedOk: boolean };
|
||||
verifyBaseUrl?: string;
|
||||
/** Local mode: test against the REAL provider API instead of a stand-in. */
|
||||
local?: boolean;
|
||||
logger: EvalLogger;
|
||||
}): Promise<CredentialValueProbe> {
|
||||
const {
|
||||
client,
|
||||
credentialType,
|
||||
credentialIdsBefore,
|
||||
foreignCredentialIds,
|
||||
fixture,
|
||||
verifyBaseUrl,
|
||||
local,
|
||||
logger,
|
||||
} = options;
|
||||
|
||||
if (!local && !verifyBaseUrl) {
|
||||
return {
|
||||
kind: 'unsupported',
|
||||
reason: 'this fixture ships no provider stand-in (no `verify` block in its manifest)',
|
||||
};
|
||||
}
|
||||
|
||||
let candidateIds: string[] = [];
|
||||
try {
|
||||
const all = await client.listCredentials();
|
||||
candidateIds = credentialsCreatedByThisBuild(all, {
|
||||
before: credentialIdsBefore,
|
||||
foreign: foreignCredentialIds,
|
||||
credentialType,
|
||||
}).map((c) => c.id);
|
||||
} catch (error: unknown) {
|
||||
return { kind: 'unsupported', reason: `could not list credentials: ${errText(error)}` };
|
||||
}
|
||||
|
||||
// EVERY candidate, not just the first: more than one may have appeared, and
|
||||
// picking one arbitrarily red a correct run whose credential was second.
|
||||
let lastRejected: CredentialValueProbe | undefined;
|
||||
let lastUnsupported: CredentialValueProbe | undefined;
|
||||
for (const credentialId of candidateIds) {
|
||||
const outcome = await probeOneCredential({
|
||||
client,
|
||||
credentialId,
|
||||
fixture,
|
||||
verifyBaseUrl,
|
||||
local,
|
||||
logger,
|
||||
});
|
||||
if (outcome.kind === 'passed') return outcome;
|
||||
if (outcome.kind === 'rejected') lastRejected = outcome;
|
||||
else lastUnsupported = outcome;
|
||||
}
|
||||
// Discard beats rejection, per this file's rule that only a request the
|
||||
// stand-in actually saw and refused may red a case: with several candidates a
|
||||
// rejection may belong to a credential that is not this agent's.
|
||||
return (
|
||||
lastUnsupported ??
|
||||
lastRejected ?? {
|
||||
kind: 'unsupported',
|
||||
reason: 'no credential was created, so there was nothing to test',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function probeOneCredential(options: {
|
||||
client: N8nClient;
|
||||
credentialId: string;
|
||||
fixture?: { verifyAttempts: number; verifiedOk: boolean };
|
||||
verifyBaseUrl?: string;
|
||||
local?: boolean;
|
||||
logger: EvalLogger;
|
||||
}): Promise<CredentialValueProbe> {
|
||||
const { client, credentialId, fixture, verifyBaseUrl, local, logger } = options;
|
||||
const attemptsBefore = fixture?.verifyAttempts ?? 0;
|
||||
try {
|
||||
const credential = await client.getCredentialForTest(credentialId);
|
||||
// Echo the data back and let n8n's `unredact` restore the blanked password
|
||||
// from storage, so the secret under test is the STORED one and nothing is
|
||||
// written. In local mode the URL is left alone, so the test goes to the
|
||||
// real provider API — a pass there proves the key is genuine and active,
|
||||
// which is a stronger claim than equality with a synthetic string.
|
||||
const result = await client.testCredential({
|
||||
...credential,
|
||||
data: local ? credential.data : { ...credential.data, url: verifyBaseUrl },
|
||||
});
|
||||
if (result.status === 'OK') return { kind: 'passed', target: local ? 'real' : 'stand-in' };
|
||||
|
||||
if (!local && fixture && fixture.verifyAttempts === attemptsBefore) {
|
||||
const reason = `n8n never reached the provider stand-in at ${verifyBaseUrl} (${result.message ?? result.status})`;
|
||||
logger.verbose(` [fixture] value check discarded — ${reason}`);
|
||||
return { kind: 'unsupported', reason };
|
||||
}
|
||||
return {
|
||||
kind: 'rejected',
|
||||
detail: result.message ?? result.status,
|
||||
target: local ? 'real' : 'stand-in',
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const reason = `the credential test could not run: ${errText(error)}`;
|
||||
logger.verbose(` [fixture] value check discarded — ${reason}`);
|
||||
return { kind: 'unsupported', reason };
|
||||
}
|
||||
}
|
||||
|
||||
function errText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** Does the haystack contain something shaped like one of this provider's keys?
|
||||
* Deliberately permissive on the tail (providers vary) and anchored on the
|
||||
* declared prefix, which is the part we can rely on. */
|
||||
function keyShapedPattern(prefix: string): RegExp {
|
||||
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`${escaped}[A-Za-z0-9_-]{12,}`, 'g');
|
||||
}
|
||||
|
||||
function keyShapedMatch(haystack: string, prefix: string): boolean {
|
||||
return keyShapedPattern(prefix).test(haystack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace provider-key-shaped runs of text.
|
||||
*
|
||||
* For LOCAL runs only, and applied to what gets PERSISTED — never to what the
|
||||
* leak check reads. In local mode the captured key is a real, working
|
||||
* credential, and `redact.ts` only redacts by key NAME, so a key the agent
|
||||
* echoed in prose or typed into a form field survives into
|
||||
* `eval-results.json` — an artifact another repo ingests and republishes. The
|
||||
* run whose leak check FAILS is exactly the run that would publish the key.
|
||||
*/
|
||||
export function redactKeyShapedSecrets(text: string, prefix: string): string {
|
||||
return text.replace(keyShapedPattern(prefix), `${prefix}[REDACTED]`);
|
||||
}
|
||||
|
||||
/** Whole-transcript variant of redactKeyShapedSecrets. Round-trips through JSON
|
||||
* rather than walking the union of step shapes — a missed variant is a leaked
|
||||
* key, and the transcript is plain data. */
|
||||
export function redactTranscriptSecrets<T>(transcript: T, prefix: string): T {
|
||||
if (transcript === undefined || transcript === null) return transcript;
|
||||
const redacted = redactKeyShapedSecrets(JSON.stringify(transcript), prefix);
|
||||
try {
|
||||
return JSON.parse(redacted) as T;
|
||||
} catch (error: unknown) {
|
||||
// Unreachable in practice — the input came from JSON.stringify and the
|
||||
// replacement is plain text. Throwing beats returning the original: that
|
||||
// would silently persist the key this exists to remove.
|
||||
throw new Error(
|
||||
`Could not re-parse the redacted transcript: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential-setup lane — composes the fixture server and the browser runtime
|
||||
// for one case, and takes them both down again.
|
||||
//
|
||||
// PAY-PER-USE is the whole point: `resolveCredentialSetupLane` returns
|
||||
// undefined for every case that is not a credential-setup case, and nothing
|
||||
// boots. No other suite ever starts a browser or opens a port.
|
||||
//
|
||||
// Selection is the case's `credentialFixture` field: a shipped fixture id for a
|
||||
// hermetic run, or the reserved id `local` for a REAL-site run in the
|
||||
// developer's own browser. The legacy tag pair (`credential-setup` + a provider
|
||||
// id) still resolves, for one release, so cases authored before the field keep
|
||||
// working.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
attachToRunningBrowser,
|
||||
startBrowserRuntime,
|
||||
type BrowserRuntime,
|
||||
} from './browser-runtime';
|
||||
import {
|
||||
findFixtureForCredentialType,
|
||||
loadProviderFixtures,
|
||||
startFixtureServer,
|
||||
type FixtureServer,
|
||||
type ProviderFixture,
|
||||
} from './fixture-server';
|
||||
import type { EvalLogger } from './logger';
|
||||
import type { N8nClient } from '../clients/n8n-client';
|
||||
|
||||
/** Reserved `credentialFixture` value: drive the REAL provider site in the
|
||||
* developer's own browser instead of a lookalike. Reserved means a fixture
|
||||
* directory may not be called this — `loadProviderFixtures` rejects it. */
|
||||
export const LOCAL_FIXTURE_ID = 'local';
|
||||
|
||||
/**
|
||||
* What the lane should do for this case. Three states rather than
|
||||
* `ProviderFixture | undefined`, so every caller handles "real site" explicitly
|
||||
* instead of inferring it from an absence.
|
||||
*/
|
||||
export type LaneSelection =
|
||||
| { kind: 'fixture'; fixture: ProviderFixture }
|
||||
| { kind: 'local' }
|
||||
| { kind: 'none' };
|
||||
|
||||
export interface CredentialSetupLane {
|
||||
/** Absent in local mode — there is no lookalike to serve. */
|
||||
fixture?: FixtureServer;
|
||||
/** True when this run drives the REAL provider site in the developer's browser. */
|
||||
local: boolean;
|
||||
browser: BrowserRuntime;
|
||||
/** Credential type the case targets — what the post-run checks look for.
|
||||
* UNKNOWN in local mode: a credential-setup case deliberately declares no
|
||||
* credentials (the agent creates one), so there is nothing to read it from.
|
||||
* Undefined means "any type" to the checks. */
|
||||
credentialType?: string;
|
||||
/** Base URL for the provider-API stand-in, when this fixture ships one.
|
||||
* Undefined => the credential-value check reports itself unverifiable. */
|
||||
verifyBaseUrl?: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the lane should do for this case.
|
||||
*
|
||||
* `credentialFixture` is the only opt-in. A case that ASKS for the lane but
|
||||
* names nothing resolvable THROWS, listing what is available — the silent
|
||||
* `undefined` this replaced let the run continue with no browser, so the agent
|
||||
* failed for the wrong reason and it read as an agent regression.
|
||||
*/
|
||||
export async function resolveCredentialSetupFixture(caseFields: {
|
||||
credentialFixture?: string;
|
||||
}): Promise<LaneSelection> {
|
||||
const { credentialFixture } = caseFields;
|
||||
|
||||
if (credentialFixture === LOCAL_FIXTURE_ID) return { kind: 'local' };
|
||||
// Answer the common case before touching disk. This runs for EVERY build of
|
||||
// EVERY case, and `loadProviderFixtures` throws on any malformed manifest —
|
||||
// so loading first meant one bad fixture directory failed every suite in the
|
||||
// repo, not just the browser lane.
|
||||
if (!credentialFixture) return { kind: 'none' };
|
||||
|
||||
const fixtures = await loadProviderFixtures();
|
||||
const available = () =>
|
||||
fixtures
|
||||
.map((f) => f.id)
|
||||
.sort()
|
||||
.join(', ');
|
||||
|
||||
const fixture = fixtures.find((f) => f.id === credentialFixture);
|
||||
if (!fixture) {
|
||||
throw new Error(
|
||||
`Unknown credentialFixture "${credentialFixture}". ` +
|
||||
`Available: ${available() || '(none)'}, or "${LOCAL_FIXTURE_ID}" for the real site.`,
|
||||
);
|
||||
}
|
||||
return { kind: 'fixture', fixture };
|
||||
}
|
||||
|
||||
/** Same decision, expressed over a credential type — for callers that know the
|
||||
* type directly (e.g. a future card/resume path, where the type arrives in the
|
||||
* resume payload rather than a tag). */
|
||||
export const resolveFixtureForCredentialType = findFixtureForCredentialType;
|
||||
|
||||
export interface StartCredentialSetupLaneOptions {
|
||||
client: N8nClient;
|
||||
/** What this run talks to. `local` boots NO fixture at all. */
|
||||
selection: LaneSelection;
|
||||
logger: EvalLogger;
|
||||
/** Credential type the checks look for, if the case happens to declare one.
|
||||
* Usually undefined for local runs — see CredentialSetupLane.credentialType. */
|
||||
localCredentialType?: string;
|
||||
}
|
||||
|
||||
export async function startCredentialSetupLane(
|
||||
options: StartCredentialSetupLaneOptions,
|
||||
): Promise<CredentialSetupLane | undefined> {
|
||||
const { client, selection, logger, localCredentialType } = options;
|
||||
if (selection.kind === 'none') return undefined;
|
||||
|
||||
// ---- Local: real provider site, developer's browser, no fixture ---------
|
||||
if (selection.kind === 'local') {
|
||||
// No fixture server: it would open a port, mint a cert for hostnames the
|
||||
// browser will never be redirected to, and issue a secret nothing can
|
||||
// reach. Previously `attended` booted one anyway.
|
||||
//
|
||||
// And no browser LAUNCH either — we attach to the one the developer is
|
||||
// already using, which is what makes their logins and their installed
|
||||
// extension available. See attachToRunningBrowser.
|
||||
const browser = await attachToRunningBrowser({ client, logger });
|
||||
logger.info(' Local mode: driving the REAL provider site in your browser');
|
||||
return {
|
||||
local: true,
|
||||
browser,
|
||||
credentialType: localCredentialType,
|
||||
close: async () => {
|
||||
await browser.close().catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Fixture: hermetic lookalike ----------------------------------------
|
||||
const { fixture } = selection;
|
||||
const fixtureServer = await startFixtureServer({ fixture, logger });
|
||||
let browser: BrowserRuntime;
|
||||
try {
|
||||
browser = await startBrowserRuntime({
|
||||
client,
|
||||
logger,
|
||||
hostResolverRules: fixtureServer.hostResolverRules(),
|
||||
headed: false,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Never leave the port open if the browser half failed.
|
||||
await fixtureServer.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
fixture: fixtureServer,
|
||||
local: false,
|
||||
browser,
|
||||
credentialType: fixture.manifest.credentialType,
|
||||
verifyBaseUrl: fixtureServer.verifyBaseUrl,
|
||||
close: async () => {
|
||||
// Browser first: it is the thing holding pages open against the fixture.
|
||||
await browser.close().catch(() => {});
|
||||
await fixtureServer.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture server — serves lookalike provider console pages AS the real
|
||||
// provider hostnames, for credential-setup evals.
|
||||
//
|
||||
// Same species as `packages/cli/.../eval/llm-wire-server.ts`: a loopback
|
||||
// listener on an OS-assigned port, started and stopped around ONE case, never
|
||||
// instance-wide. The difference is who is fooled — the wire server intercepts
|
||||
// vendor SDK calls from the n8n process, this one serves page loads to the
|
||||
// eval browser.
|
||||
//
|
||||
// Interception is NOT proxy-based. The browser is launched with
|
||||
// `--host-resolver-rules=MAP console.anthropic.com 127.0.0.1:<port>` plus
|
||||
// `--ignore-certificate-errors` (see `browser-runtime.ts`), so the agent
|
||||
// navigates to the real hostname and this server answers. A forward proxy
|
||||
// would additionally have to terminate TLS per host on CONNECT — a MITM CA for
|
||||
// no extra benefit. Debugger-based interception is off the table entirely: it
|
||||
// collides with the extension's own `chrome.debugger` session (NODE-4979).
|
||||
//
|
||||
// What is stored here is layout only — our own generic HTML. Never provider
|
||||
// source, never recorded provider responses, never real tokens.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { readFile, readdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
|
||||
import { createServer, type Server } from 'node:https';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { EvalLogger } from './logger';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Bind + advertise addresses. Loopback by default: on a dev laptop the fixture
|
||||
* must not be reachable from the LAN. A CONTAINERISED run needs both overridden
|
||||
* — the fixture lives in the dispatcher container while n8n lives in another,
|
||||
* so n8n cannot reach the dispatcher's loopback. Leaving them unset there is
|
||||
* safe: the credential test simply reports itself unverifiable.
|
||||
*/
|
||||
function envHost(name: string): string {
|
||||
const raw = process.env[name]?.trim();
|
||||
return raw !== undefined && raw.length > 0 ? raw : '127.0.0.1';
|
||||
}
|
||||
const BIND_HOST = envHost('N8N_EVAL_FIXTURE_BIND');
|
||||
const ADVERTISE_HOST = envHost('N8N_EVAL_FIXTURE_ADVERTISE_HOST');
|
||||
|
||||
/** Reserved fixture id — kept here (not imported from the lane) to avoid a
|
||||
* cycle; the lane re-exports it as LOCAL_FIXTURE_ID. */
|
||||
const RESERVED_LOCAL_FIXTURE_ID = 'local';
|
||||
|
||||
/** Where provider fixtures live, relative to this file. */
|
||||
const FIXTURES_DIR = join(__dirname, '..', 'fixtures', 'providers');
|
||||
|
||||
/**
|
||||
* Shape of a provider fixture's `manifest.json`, as a zod schema.
|
||||
*
|
||||
* Parsed rather than cast: a manifest is hand-written JSON with no compiler
|
||||
* behind it, so a typo'd key or a missing route used to surface much later as
|
||||
* a confusing runtime failure (or, worse, as the default page being served for
|
||||
* a route nobody noticed was misspelled). `.strict()` turns an unknown key into
|
||||
* an error at load instead of silence.
|
||||
*/
|
||||
export const providerFixtureManifestSchema = z
|
||||
.object({
|
||||
/** n8n credential type this fixture stands in for, e.g. `anthropicApi`.
|
||||
* Fixture selection is derived from the case's credential type through
|
||||
* this field — deliberately NOT a case-schema field. */
|
||||
credentialType: z.string().min(1),
|
||||
/** Hostnames to route into this server. */
|
||||
hosts: z.array(z.string().min(1)).min(1),
|
||||
/** Prefix real keys of this provider carry, so a leak scan and any
|
||||
* prefix-sniffing code sees a realistic shape. */
|
||||
secretPrefix: z.string().min(1),
|
||||
/** URL path → HTML file in the fixture directory. */
|
||||
routes: z.record(z.string(), z.string().min(1)),
|
||||
/** Path served for any request that matches no route. Real consoles
|
||||
* redirect liberally; a 404 would strand the agent for the wrong reason. */
|
||||
defaultRoute: z.string().min(1),
|
||||
/** OPTIONAL provider-API stand-in, used to prove the SAVED credential really
|
||||
* authenticates. n8n runs the credential's own test request against this
|
||||
* instead of the real provider, and only the minted key is accepted — so a
|
||||
* pass proves the stored value without anyone ever reading it back.
|
||||
*
|
||||
* Plain HTTP on its own port, deliberately: the browser-facing listener is
|
||||
* HTTPS-with-a-self-signed-cert so it can impersonate a hostname, and n8n's
|
||||
* HTTP client would (correctly) reject that cert. Omit the block entirely
|
||||
* and the check reports itself unverifiable rather than failing. */
|
||||
verify: z
|
||||
.object({
|
||||
/** Path the credential's test request hits, e.g. `/v1/models`. */
|
||||
path: z.string().startsWith('/'),
|
||||
/** Header the credential type sends its secret in, e.g. `x-api-key`. */
|
||||
header: z.string().min(1),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
// Was a throw after the TLS cert had already been generated; as a refinement
|
||||
// it fails at load, with the offending value in the message.
|
||||
.refine(
|
||||
(m) => Object.keys(m.routes).includes(m.defaultRoute),
|
||||
(m) => ({
|
||||
message: `defaultRoute "${m.defaultRoute}" is not one of routes (${Object.keys(m.routes).join(', ')})`,
|
||||
path: ['defaultRoute'],
|
||||
}),
|
||||
);
|
||||
|
||||
export type ProviderFixtureManifest = z.infer<typeof providerFixtureManifestSchema>;
|
||||
|
||||
export interface ProviderFixture {
|
||||
/** Directory name, also the fixture id. */
|
||||
id: string;
|
||||
dir: string;
|
||||
manifest: ProviderFixtureManifest;
|
||||
}
|
||||
|
||||
/** One request the fixture answered. Feeds failure attribution: a red with an
|
||||
* empty event log is a harness problem, a red with page loads but no key
|
||||
* creation is an agent problem. */
|
||||
export interface FixtureEvent {
|
||||
method: string;
|
||||
host: string;
|
||||
path: string;
|
||||
/** Set when this request minted the secret. */
|
||||
mintedSecret?: boolean;
|
||||
/** Set on a credential-test request: whether the presented key was accepted. */
|
||||
verifyOk?: boolean;
|
||||
}
|
||||
|
||||
export interface FixtureServer {
|
||||
port: number;
|
||||
/** Base URL n8n should point the credential's test request at, when this
|
||||
* fixture stands in for the provider API. Undefined when the fixture
|
||||
* declares no `verify` block — the value check then reports itself
|
||||
* unverifiable instead of failing. */
|
||||
verifyBaseUrl?: string;
|
||||
/** True once the verify endpoint accepted the minted key — evidence the
|
||||
* check actually exercised the provider path. */
|
||||
verifiedOk: boolean;
|
||||
/** How many credential-test requests reached the stand-in. ZERO means n8n
|
||||
* never got here (unreachable across a container boundary, say), which is a
|
||||
* harness limitation — NOT a wrong credential. The classifier keys on this
|
||||
* rather than on n8n's error prose. */
|
||||
verifyAttempts: number;
|
||||
hosts: string[];
|
||||
/** The exact secret this run's page will hand out — the ledger the
|
||||
* "correct value" check compares against. */
|
||||
mintedSecret: string;
|
||||
events: FixtureEvent[];
|
||||
/** True once the page's create-key action was actually invoked. */
|
||||
secretWasIssued: boolean;
|
||||
/** This provider's key prefix, from the manifest — the shape a leak scan looks for. */
|
||||
manifestSecretPrefix: string;
|
||||
/** Chromium flag mapping every fixture host — AND every other host — to this
|
||||
* server, so a fixture run cannot reach the real internet. */
|
||||
hostResolverRules(): string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Load every provider fixture that ships in the evaluations package. */
|
||||
export async function loadProviderFixtures(): Promise<ProviderFixture[]> {
|
||||
const entries = await readdir(FIXTURES_DIR, { withFileTypes: true }).catch(() => []);
|
||||
const fixtures: ProviderFixture[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('_')) continue;
|
||||
// `local` is the reserved id meaning "the real provider site". A fixture
|
||||
// directory of that name would shadow the keyword and silently turn a
|
||||
// real-site run into a lookalike one.
|
||||
if (entry.name === RESERVED_LOCAL_FIXTURE_ID) {
|
||||
throw new Error(
|
||||
`Fixture directory "${RESERVED_LOCAL_FIXTURE_ID}" is reserved — it is the id that means "run against the real provider site". Rename it.`,
|
||||
);
|
||||
}
|
||||
const dir = join(FIXTURES_DIR, entry.name);
|
||||
const raw = await readFile(join(dir, 'manifest.json'), 'utf8').catch(() => null);
|
||||
if (!raw) continue;
|
||||
const parsed = providerFixtureManifestSchema.safeParse(jsonParse<unknown>(raw));
|
||||
if (!parsed.success) {
|
||||
const detail = parsed.error.issues
|
||||
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
||||
.join('; ');
|
||||
throw new Error(`Fixture ${entry.name}: invalid manifest.json — ${detail}`);
|
||||
}
|
||||
fixtures.push({ id: entry.name, dir, manifest: parsed.data });
|
||||
}
|
||||
return fixtures;
|
||||
}
|
||||
|
||||
/** Resolve the fixture for a credential type. Returns undefined when none
|
||||
* covers it — the caller then runs the case without a browser rather than
|
||||
* inventing a page. */
|
||||
export async function findFixtureForCredentialType(
|
||||
credentialType: string,
|
||||
): Promise<ProviderFixture | undefined> {
|
||||
const fixtures = await loadProviderFixtures();
|
||||
return fixtures.find((f) => f.manifest.credentialType === credentialType);
|
||||
}
|
||||
|
||||
/** Mint a synthetic secret carrying the provider's real prefix. Random, not
|
||||
* seeded: two runs must never share a secret, or a leak scan could pass by
|
||||
* matching the wrong run's value. */
|
||||
export function mintSecret(prefix: string): string {
|
||||
return `${prefix}${randomBytes(24).toString('base64url')}`;
|
||||
}
|
||||
|
||||
/** Self-signed cert for the fixture hosts. The browser is launched with
|
||||
* `--ignore-certificate-errors`, so this only has to exist — but it names the
|
||||
* hosts anyway so a manual `curl --resolve` session is pleasant.
|
||||
* Shells out to openssl (present on macOS and GitHub runners) rather than
|
||||
* adding a crypto dependency or committing a private key to the repo. */
|
||||
async function generateSelfSignedCert(
|
||||
hosts: string[],
|
||||
): Promise<{ key: string; cert: string; dir: string }> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'eval-fixture-cert-'));
|
||||
const san = hosts.map((h) => `DNS:${h}`).join(',');
|
||||
try {
|
||||
await execFileAsync('openssl', [
|
||||
'req',
|
||||
'-x509',
|
||||
'-newkey',
|
||||
'rsa:2048',
|
||||
'-nodes',
|
||||
'-keyout',
|
||||
join(dir, 'key.pem'),
|
||||
'-out',
|
||||
join(dir, 'cert.pem'),
|
||||
'-days',
|
||||
'1',
|
||||
'-subj',
|
||||
`/CN=${hosts[0] ?? 'fixture.local'}`,
|
||||
'-addext',
|
||||
`subjectAltName=${san}`,
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
throw new Error(
|
||||
`Fixture server needs \`openssl\` on PATH to mint its TLS cert: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const [key, cert] = await Promise.all([
|
||||
readFile(join(dir, 'key.pem'), 'utf8'),
|
||||
readFile(join(dir, 'cert.pem'), 'utf8'),
|
||||
]);
|
||||
return { key, cert, dir };
|
||||
}
|
||||
|
||||
/** Path the fixture page POSTs to when the agent creates a key. Namespaced so
|
||||
* it can't collide with a path the lookalike page models. */
|
||||
export const MINT_PATH = '/__fixture__/create-key';
|
||||
|
||||
export interface StartFixtureServerOptions {
|
||||
fixture: ProviderFixture;
|
||||
logger: EvalLogger;
|
||||
/** Override the minted secret (tests only). */
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export async function startFixtureServer(
|
||||
options: StartFixtureServerOptions,
|
||||
): Promise<FixtureServer> {
|
||||
const { fixture, logger } = options;
|
||||
const { manifest } = fixture;
|
||||
const mintedSecret = options.secret ?? mintSecret(manifest.secretPrefix);
|
||||
const events: FixtureEvent[] = [];
|
||||
let secretWasIssued = false;
|
||||
|
||||
const { key, cert, dir: certDir } = await generateSelfSignedCert(manifest.hosts);
|
||||
|
||||
// Pages are read once at boot — a fixture must not change mid-run.
|
||||
const pages = new Map<string, string>();
|
||||
for (const [route, file] of Object.entries(manifest.routes)) {
|
||||
pages.set(route, await readFile(join(fixture.dir, file), 'utf8'));
|
||||
}
|
||||
// Non-null: the schema's refinement guarantees defaultRoute is a declared route.
|
||||
const defaultPage = pages.get(manifest.defaultRoute)!;
|
||||
|
||||
const server = createServer({ key, cert }, (req, res) => {
|
||||
const host = (req.headers.host ?? '').split(':')[0];
|
||||
const path = (req.url ?? '/').split('?')[0];
|
||||
|
||||
if (req.method === 'POST' && path === MINT_PATH) {
|
||||
secretWasIssued = true;
|
||||
events.push({ method: 'POST', host, path, mintedSecret: true });
|
||||
logger.verbose(` [fixture] issued secret to ${host}`);
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ key: mintedSecret }));
|
||||
return;
|
||||
}
|
||||
|
||||
events.push({ method: req.method ?? 'GET', host, path });
|
||||
if (path === '/favicon.ico') {
|
||||
res.writeHead(204).end();
|
||||
return;
|
||||
}
|
||||
const body = pages.get(path) ?? defaultPage;
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(body);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, BIND_HOST, () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
logger.info(
|
||||
` Fixture ${fixture.id} serving ${manifest.hosts.join(', ')} on ${BIND_HOST}:${port}`,
|
||||
);
|
||||
|
||||
// Provider-API stand-in for the credential test. n8n calls this, not the
|
||||
// browser, so it is plain HTTP on its own port (see the manifest comment).
|
||||
let verifyServer: HttpServer | undefined;
|
||||
let verifyBaseUrl: string | undefined;
|
||||
let verifiedOk = false;
|
||||
let verifyAttempts = 0;
|
||||
if (manifest.verify) {
|
||||
const { path: verifyPath, header } = manifest.verify;
|
||||
verifyServer = createHttpServer((req, res) => {
|
||||
const path = (req.url ?? '/').split('?')[0];
|
||||
if (path !== verifyPath) {
|
||||
events.push({ method: req.method ?? 'GET', host: 'verify', path });
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
// ONLY the minted key authenticates. That is the whole proof: a
|
||||
// truncated or re-typed key cannot pass, so a 200 means the stored
|
||||
// value is exactly what the page issued — without reading it back.
|
||||
const presented = req.headers[header.toLowerCase()];
|
||||
const ok = typeof presented === 'string' && presented === mintedSecret;
|
||||
verifyAttempts += 1;
|
||||
if (ok) verifiedOk = true;
|
||||
events.push({ method: req.method ?? 'GET', host: 'verify', path, verifyOk: ok });
|
||||
logger.verbose(` [fixture] credential test ${ok ? 'ACCEPTED' : 'REJECTED'}`);
|
||||
res.writeHead(ok ? 200 : 401, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(ok ? { data: [{ id: 'fixture-model' }] } : { error: 'invalid key' }));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
verifyServer!.once('error', reject);
|
||||
verifyServer!.listen(0, BIND_HOST, () => resolve());
|
||||
});
|
||||
const vAddr = verifyServer.address();
|
||||
const vPort = typeof vAddr === 'object' && vAddr ? vAddr.port : 0;
|
||||
verifyBaseUrl = `http://${ADVERTISE_HOST}:${vPort}`;
|
||||
logger.info(
|
||||
` Fixture ${fixture.id} credential-test endpoint at ${verifyBaseUrl}${verifyPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
hosts: manifest.hosts,
|
||||
manifestSecretPrefix: manifest.secretPrefix,
|
||||
mintedSecret,
|
||||
events,
|
||||
verifyBaseUrl,
|
||||
get verifiedOk() {
|
||||
return verifiedOk;
|
||||
},
|
||||
get verifyAttempts() {
|
||||
return verifyAttempts;
|
||||
},
|
||||
get secretWasIssued() {
|
||||
return secretWasIssued;
|
||||
},
|
||||
hostResolverRules() {
|
||||
// Declared hosts first, then a WILDCARD catch-all. Without the
|
||||
// catch-all the run is only hermetic for hosts we happened to list —
|
||||
// verified: an unlisted host (docs.anthropic.com, example.com) reaches
|
||||
// the real internet, so an agent that follows a link silently leaves
|
||||
// the fixture. First-match-wins, so the specific relay rule the caller
|
||||
// appends still beats this.
|
||||
return [
|
||||
...manifest.hosts.map((h) => `MAP ${h} 127.0.0.1:${port}`),
|
||||
`MAP * 127.0.0.1:${port}`,
|
||||
].join(',');
|
||||
},
|
||||
async close() {
|
||||
// `close()` only stops NEW connections; it resolves when the last live
|
||||
// one ends. n8n's HTTP client keep-alives against the verify listener,
|
||||
// so one retained socket would hang this forever — and it is awaited
|
||||
// from buildWorkflow's `finally`, so that hangs the whole run, not just
|
||||
// the case. Drop the sockets explicitly.
|
||||
const shutDown = async (target: typeof server | typeof verifyServer) => {
|
||||
if (!target) return;
|
||||
const closed = new Promise<void>((resolve) => target.close(() => resolve()));
|
||||
target.closeAllConnections();
|
||||
await closed;
|
||||
};
|
||||
await shutDown(server);
|
||||
await shutDown(verifyServer);
|
||||
await rm(certDir, { recursive: true, force: true });
|
||||
},
|
||||
} satisfies FixtureServer;
|
||||
}
|
||||
|
||||
/** Kept for callers that want the raw server type without importing node:https. */
|
||||
export type { Server as FixtureHttpServer };
|
||||
@@ -170,6 +170,21 @@ const evalTestCaseObjectSchema = z
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
/**
|
||||
* Opts this case into the credential-setup BROWSER lane, and picks what the
|
||||
* browser talks to. Replaces the old tag-pair convention, which you had to
|
||||
* know the magic strings for and which failed silently when half-specified.
|
||||
*
|
||||
* "anthropic" (any shipped fixture id) → hermetic run against a lookalike
|
||||
* page served AS the real hostname
|
||||
* "local" → REAL provider site in the
|
||||
* developer's own Chrome
|
||||
*
|
||||
* Omitted → no browser lane. Absence never means "real internet"; that
|
||||
* requires choosing `local` explicitly. An unknown id fails the run with
|
||||
* the available ids rather than silently booting nothing.
|
||||
*/
|
||||
credentialFixture: z.string().min(1).optional(),
|
||||
/** History restored before the live turn — one slot, `mode` says where it
|
||||
* comes from. See `CaseSeedSchema`. */
|
||||
seed: CaseSeedSchema.optional(),
|
||||
|
||||
@@ -31,6 +31,7 @@ const COMPARED_KEYS = [
|
||||
'outcomeExpectations',
|
||||
'messageBudget',
|
||||
'credentials',
|
||||
'credentialFixture',
|
||||
'datasets',
|
||||
// Round-trips faithfully: PATCH /cases/:id reconciles scenario rows by name
|
||||
// (lang-tracer #48) and the export emits them back in disk shape.
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface LangTracerCreateCaseBody {
|
||||
* Only the authored arm: a replay seed is derived from a source thread by
|
||||
* promote/scrub over there, so pushing one would fabricate provenance. */
|
||||
seed?: Extract<CaseSeed, { mode: 'inline' }>;
|
||||
credentialFixture?: string;
|
||||
}
|
||||
|
||||
export interface ToLangTracerOptions {
|
||||
@@ -109,6 +110,7 @@ export function diskCaseToLangTracerCreate(
|
||||
if (testCase.credentials !== undefined) body.credentials = testCase.credentials;
|
||||
// Replay never reaches here — `unsupportedPushReason` skips those cases upstream.
|
||||
if (testCase.seed?.mode === 'inline') body.seed = testCase.seed;
|
||||
if (testCase.credentialFixture !== undefined) body.credentialFixture = testCase.credentialFixture;
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -148,7 +148,20 @@ export function aggregateResults(
|
||||
}
|
||||
|
||||
// Aggregate each build expectation as a measured unit alongside scenarios.
|
||||
const buildExpectations: BuildExpectationAggregation[] = collectExpectations(testCase).map(
|
||||
// Declared expectations first, then any the harness INJECTED (the
|
||||
// deterministic credential-setup checks). Driving this off the case alone
|
||||
// silently dropped injected verdicts from the pass rate, the summary and
|
||||
// the case status — they were computed and then thrown away.
|
||||
const declared = collectExpectations(testCase);
|
||||
const injected = [
|
||||
...new Set(
|
||||
runs
|
||||
.flatMap((r) => r.buildExpectationResults ?? [])
|
||||
.map((e) => e.expectation)
|
||||
.filter((text) => !declared.includes(text)),
|
||||
),
|
||||
];
|
||||
const buildExpectations: BuildExpectationAggregation[] = [...declared, ...injected].map(
|
||||
(expectation) => ({
|
||||
expectation,
|
||||
...aggregateUnit<BuildExpectationResult>(
|
||||
|
||||
@@ -31,9 +31,20 @@ import {
|
||||
} from '../harness/agent-execution';
|
||||
import { resolveArtifactContext } from '../harness/artifacts/artifact-context';
|
||||
import { attributionForExpectation } from '../harness/attribution';
|
||||
import { buildFailedOnInfra, type BuildResult } from '../harness/build-workflow';
|
||||
import {
|
||||
buildFailedOnInfra,
|
||||
leakHaystackFor,
|
||||
redactLocalRunSecrets,
|
||||
searchableBuildText,
|
||||
scrubLocalSecretsFromBuild,
|
||||
type BuildResult,
|
||||
} from '../harness/build-workflow';
|
||||
import { captureThreadRunDebug } from '../harness/capture-run-debug';
|
||||
import { effectiveTimeoutMs, runWorkflowChecks } from '../harness/cleanup';
|
||||
import {
|
||||
credentialSetupExpectationTexts,
|
||||
runCredentialSetupChecks,
|
||||
} from '../harness/credential-setup-checks';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import {
|
||||
fetchPrebuiltBuild,
|
||||
@@ -98,6 +109,12 @@ export type BuildArgs = Pick<
|
||||
| 'seed'
|
||||
| 'executionScenarios'
|
||||
| 'outcomeExpectations'
|
||||
// Load-bearing, not metadata: the credential-setup lane is selected from
|
||||
// this, and a build that never receives it silently runs without a browser —
|
||||
// the case then fails as if the AGENT had misbehaved. `wrap()` erases the
|
||||
// callback's parameter type, so tsc cannot catch a dropped field here; the
|
||||
// orchestrator test pins it.
|
||||
| 'credentialFixture'
|
||||
> & { timeoutMs: number };
|
||||
|
||||
/** A lane plus the allocator-managed counters and the caller-provided (traced)
|
||||
@@ -345,6 +362,7 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche
|
||||
const buildDurations = new Map<string, number>();
|
||||
|
||||
function stashTranscript(build: BuildResult): void {
|
||||
scrubLocalSecretsFromBuild(build);
|
||||
if (build.threadId && build.transcript) {
|
||||
transcriptByThreadId.set(build.threadId, build.transcript);
|
||||
}
|
||||
@@ -360,7 +378,21 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche
|
||||
|
||||
function stashRunDebug(client: N8nClient, build: BuildResult): void {
|
||||
if (!build.threadId) return;
|
||||
runDebugByThreadId.set(build.threadId, captureThreadRunDebug(client, build.threadId, logger));
|
||||
// Re-read from n8n AFTER the build was scrubbed, so it arrives raw and the
|
||||
// run-debug report would render a local run's real key verbatim.
|
||||
runDebugByThreadId.set(
|
||||
build.threadId,
|
||||
captureThreadRunDebug(client, build.threadId, logger)
|
||||
.then((debug) => redactLocalRunSecrets(debug, build.credentialSetup))
|
||||
// Drop the payload rather than ship it or kill the run: run debug is
|
||||
// diagnostic, and an unscrubable local run must not reach the report.
|
||||
.catch((error: unknown) => {
|
||||
logger.warn(
|
||||
` Dropped run debug for thread ${build.threadId ?? '?'}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Judge author expectations once per build (off the scenario critical path);
|
||||
@@ -376,8 +408,36 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche
|
||||
build: BuildResult,
|
||||
isPrebuilt: boolean,
|
||||
): void {
|
||||
// `scrubLocalSecrets` (in stashTranscript, which always runs first) has
|
||||
// already redacted a local run's transcript and kept the pre-scrub text
|
||||
// off-build for exactly this check.
|
||||
// Hermetic mode scrubs nothing, so there is no snapshot — but the surfaces
|
||||
// scanned must be the same ones, hence the shared builder.
|
||||
const searchableRunText =
|
||||
(build.credentialSetup && leakHaystackFor(build.credentialSetup)) ??
|
||||
searchableBuildText(build);
|
||||
const testCase = testCaseByFileSlug.get(fileSlug);
|
||||
if (!testCase) return;
|
||||
// Deterministic credential-setup verdicts, started EAGERLY: per-build
|
||||
// cleanup deletes artifacts later, and a credential read that lost that
|
||||
// race would report "not created" for a run that did create one.
|
||||
const injected = build.credentialSetup
|
||||
? runCredentialSetupChecks({
|
||||
client,
|
||||
facts: build.credentialSetup,
|
||||
searchableRunText,
|
||||
logger,
|
||||
}).catch((error: unknown) => {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(` Credential-setup checks failed: ${reason}`);
|
||||
// Incomplete, not dropped: an empty array let the case pass on
|
||||
// authored expectations with nothing deterministic behind it.
|
||||
return allFailVerdicts(
|
||||
credentialSetupExpectationTexts(build.credentialSetup?.credentialType),
|
||||
`Credential-setup checks could not run: ${reason}`,
|
||||
);
|
||||
})
|
||||
: undefined;
|
||||
const { expectations, transcript, unjudged } = selectAuthorExpectations({
|
||||
testCase,
|
||||
transcript: build.transcript,
|
||||
@@ -392,37 +452,51 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche
|
||||
const infraFailed = buildFailedOnInfra(build);
|
||||
const attribute = (verdicts: BuildExpectationResult[]): BuildExpectationResult[] =>
|
||||
verdicts.map((v) => ({ ...v, attribution: attributionForExpectation(v, infraFailed) }));
|
||||
// The lane's deterministic verdicts ride along on EVERY path, including the
|
||||
// unjudged one: they describe what the run actually did to the provider and
|
||||
// to n8n, which stays true whether or not the author expectations got judged.
|
||||
// Deliberately not passed through `attribute` — that answers "is this the
|
||||
// agent's miss or infra's", and these are measurements, not judgements.
|
||||
const withInjected = async (
|
||||
verdicts: BuildExpectationResult[] | Promise<BuildExpectationResult[]>,
|
||||
): Promise<BuildExpectationResult[]> =>
|
||||
injected ? [...(await verdicts), ...(await injected)] : await verdicts;
|
||||
// Recorded as incomplete rather than dropped, so the case keeps its unit
|
||||
// count and the report says why they weren't graded.
|
||||
if (unjudged.length > 0) {
|
||||
buildExpectationsByKey.set(key, Promise.resolve(attribute(unjudged)));
|
||||
buildExpectationsByKey.set(key, withInjected(attribute(unjudged)));
|
||||
return;
|
||||
}
|
||||
if (expectations.length === 0) {
|
||||
if (injected) buildExpectationsByKey.set(key, injected);
|
||||
return;
|
||||
}
|
||||
if (expectations.length === 0) return;
|
||||
buildExpectationsByKey.set(
|
||||
key,
|
||||
(async () =>
|
||||
await verifyBuildExpectations(expectations, {
|
||||
transcript,
|
||||
workflowJson: build.workflowJsons[0],
|
||||
metrics: build.conversationMetrics,
|
||||
// Rendered non-workflow artifacts (agent AND config-eval), sectioned
|
||||
// with "(no <type> produced)" fallbacks, so outcome expectations can
|
||||
// judge artifact existence, absence and content — parity with the
|
||||
// retired direct loop, which always threaded resolveArtifactContext.
|
||||
artifactContext: await resolveArtifactContext({
|
||||
artifactRefs: build.artifactRefs ?? [],
|
||||
client,
|
||||
logger,
|
||||
}),
|
||||
}))()
|
||||
.catch((error: unknown) =>
|
||||
allFailVerdicts(
|
||||
expectations,
|
||||
`judge error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
)
|
||||
.then(attribute),
|
||||
withInjected(
|
||||
(async () =>
|
||||
await verifyBuildExpectations(expectations, {
|
||||
transcript,
|
||||
workflowJson: build.workflowJsons[0],
|
||||
metrics: build.conversationMetrics,
|
||||
// Rendered non-workflow artifacts (agent AND config-eval), sectioned
|
||||
// with "(no <type> produced)" fallbacks, so outcome expectations can
|
||||
// judge artifact existence, absence and content — parity with the
|
||||
// retired direct loop, which always threaded resolveArtifactContext.
|
||||
artifactContext: await resolveArtifactContext({
|
||||
artifactRefs: build.artifactRefs ?? [],
|
||||
client,
|
||||
logger,
|
||||
}),
|
||||
}))()
|
||||
.catch((error: unknown) =>
|
||||
allFailVerdicts(
|
||||
expectations,
|
||||
`judge error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
)
|
||||
.then(attribute),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -548,6 +622,7 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche
|
||||
seed: entry.seed,
|
||||
executionScenarios: entry.executionScenarios,
|
||||
outcomeExpectations: entry.outcomeExpectations,
|
||||
credentialFixture: entry.credentialFixture,
|
||||
timeoutMs,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -26,10 +26,12 @@ import type { WorkflowTestCaseWithFile } from '../data/workflows';
|
||||
import { executeAgentScenario } from '../harness/agent-execution';
|
||||
import {
|
||||
buildWorkflow,
|
||||
scrubLocalSecretsFromBuild,
|
||||
workflowExpectedForCase,
|
||||
type BuildResult,
|
||||
} from '../harness/build-workflow';
|
||||
import { cleanupBuild } from '../harness/cleanup';
|
||||
import { resolveCredentialSetupFixture } from '../harness/credential-setup-lane';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import type { PrebuiltManifest } from '../harness/prebuilt-workflows';
|
||||
import { executeScenario } from '../harness/scenario-execution';
|
||||
@@ -134,23 +136,32 @@ export function createEvalSession(config: EvalSessionConfig): EvalSession {
|
||||
tracedBuild: wrap(
|
||||
'workflow_build',
|
||||
laneNum,
|
||||
// Scrubbed INSIDE the wrapper: `traceable` records this function's
|
||||
// return value, so a local run's real key would reach LangSmith
|
||||
// before any later redaction could touch it.
|
||||
async (buildArgs: BuildArgs) =>
|
||||
await buildWorkflow({
|
||||
client: lane.client,
|
||||
conversation: buildArgs.conversation,
|
||||
messageBudget: buildArgs.messageBudget,
|
||||
credentials: buildArgs.credentials,
|
||||
seed: buildArgs.seed,
|
||||
executionScenarios: buildArgs.executionScenarios,
|
||||
createdCredentialIds: lane.createdCredentialIds,
|
||||
timeoutMs: buildArgs.timeoutMs,
|
||||
preRunWorkflowIds: lane.preRunWorkflowIds,
|
||||
preRunDataTableIds: lane.preRunDataTableIds,
|
||||
claimedWorkflowIds: lane.claimedWorkflowIds,
|
||||
logger,
|
||||
laneTag,
|
||||
workflowExpected: workflowExpectedForCase(buildArgs),
|
||||
}),
|
||||
scrubLocalSecretsFromBuild(
|
||||
await buildWorkflow({
|
||||
client: lane.client,
|
||||
conversation: buildArgs.conversation,
|
||||
messageBudget: buildArgs.messageBudget,
|
||||
credentials: buildArgs.credentials,
|
||||
seed: buildArgs.seed,
|
||||
executionScenarios: buildArgs.executionScenarios,
|
||||
createdCredentialIds: lane.createdCredentialIds,
|
||||
timeoutMs: buildArgs.timeoutMs,
|
||||
preRunWorkflowIds: lane.preRunWorkflowIds,
|
||||
preRunDataTableIds: lane.preRunDataTableIds,
|
||||
claimedWorkflowIds: lane.claimedWorkflowIds,
|
||||
logger,
|
||||
laneTag,
|
||||
workflowExpected: workflowExpectedForCase(buildArgs),
|
||||
// `{kind:'none'}` for every case that hasn't opted in, so no browser
|
||||
// launches and no port opens.
|
||||
credentialSetupSelection: await resolveCredentialSetupFixture(buildArgs),
|
||||
credentialSetupType: buildArgs.credentials?.[0]?.type,
|
||||
}),
|
||||
),
|
||||
),
|
||||
tracedExecute: wrap(
|
||||
'scenario_execution',
|
||||
|
||||
@@ -251,6 +251,11 @@ export interface WorkflowTestCase {
|
||||
* field build with an empty view (everything mocks).
|
||||
*/
|
||||
credentials?: TestCaseCredential[];
|
||||
/** Opts into the credential-setup BROWSER lane and picks what it talks to:
|
||||
* a shipped fixture id (hermetic lookalike) or `local` (the REAL provider
|
||||
* site in the developer's own Chrome). Omitted → no browser lane; absence
|
||||
* never means real internet. */
|
||||
credentialFixture?: string;
|
||||
/** History restored before the live turn, in one slot so the modes can't
|
||||
* overlap: `mode: 'inline'` carries the messages (and the workflows/tables
|
||||
* they reference) in the case body; `mode: 'replay'` reconstructs them from a
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"eval:agents": "tsx evaluations/cli/index.ts --source langtracer --suite agents --tier agents",
|
||||
"eval:build-mcp-manifest": "tsx evaluations/cli/build-mcp-manifest.ts",
|
||||
"eval:langtracer-push": "tsx evaluations/cli/langtracer-push.ts",
|
||||
"eval:serve-fixture": "tsx evaluations/cli/serve-fixture.ts",
|
||||
"eval:pairwise": "tsx evaluations/cli/pairwise.ts",
|
||||
"eval:pairwise:report": "tsx evaluations/cli/report.ts",
|
||||
"eval:pairwise:compare": "tsx evaluations/cli/compare-pairwise.ts",
|
||||
@@ -55,6 +56,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@daytona/sdk": "catalog:",
|
||||
"@e965/xlsx": "catalog:",
|
||||
"@joplin/turndown-plugin-gfm": "catalog:",
|
||||
"@langchain/anthropic": "catalog:",
|
||||
"@mozilla/readability": "catalog:",
|
||||
@@ -82,7 +84,6 @@
|
||||
"source-map-support": "catalog:",
|
||||
"turndown": "catalog:",
|
||||
"undici": "catalog:undici-v7",
|
||||
"@e965/xlsx": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-from-json-schema-v3": "npm:zod-from-json-schema@^0.0.5"
|
||||
},
|
||||
@@ -95,6 +96,7 @@
|
||||
"@types/psl": "1.1.3",
|
||||
"@types/turndown": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"playwright-core": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:typescript",
|
||||
"vite": "catalog:",
|
||||
|
||||
@@ -3,12 +3,18 @@ export type { BrowserConnectionOptions } from './connection';
|
||||
export { CDPRelayServer } from './cdp-relay';
|
||||
export type { CDPRelayServerOptions } from './cdp-relay';
|
||||
export { BROWSER_USE_EXTENSION_ID, buildExtensionConnectUrl } from './extension-connect';
|
||||
// Exposed for the eval harness's local (real-site) browser mode, which needs the
|
||||
// developer's installed browser AND its profile directory. Export-only: no
|
||||
// behaviour here changes.
|
||||
export { BrowserDiscovery, getDefaultDiscovery } from './browser-discovery';
|
||||
export { createBrowserTools } from './tools/index';
|
||||
export { configureLogger } from './logger';
|
||||
export type { LogLevel } from './logger';
|
||||
export { parseServerOptions } from './server-config';
|
||||
export type { ServerOptions } from './server-config';
|
||||
export type {
|
||||
BrowserInfo,
|
||||
DiscoveredBrowsers,
|
||||
BrowserName,
|
||||
BrowserToolkit,
|
||||
Config,
|
||||
|
||||
Generated
+3
@@ -2755,6 +2755,9 @@ importers:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.9(@vitest/browser@4.1.10)(vitest@4.1.9)
|
||||
playwright-core:
|
||||
specifier: 'catalog:'
|
||||
version: 1.60.0
|
||||
tsx:
|
||||
specifier: 'catalog:'
|
||||
version: 4.19.3
|
||||
|
||||
Reference in New Issue
Block a user