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:
Bernhard Wittmann
2026-08-13 15:56:24 +00:00
committed by GitHub
parent e1cd75734b
commit 62d5de3ec7
32 changed files with 3738 additions and 50 deletions
@@ -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);
}
/**
@@ -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
+3 -1
View File
@@ -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:",
+6
View File
@@ -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,
+3
View File
@@ -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