test(core): Add openai, gemini and slack credential-setup eval fixtures (no-changelog) (#36461)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bernhard Wittmann 2026-08-21 06:27:16 +00:00 committed by GitHub
parent 2c5db29175
commit 44041d5d3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1787 additions and 70 deletions

View File

@ -1,12 +1,15 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import type { N8nClient } from '../clients/n8n-client';
import {
credentialSetupExpectationTexts,
evaluateCredentialSetup,
probeCredentialValue,
redactTranscriptSecrets,
runCredentialSetupChecks,
type CredentialSetupFacts,
} from '../harness/credential-setup-checks';
import { createLogger } from '../harness/logger';
const SECRET = 'sk-ant-api03-abcdefghijklmnopqrstuvwx';
@ -326,3 +329,83 @@ describe('credentialSetupExpectationTexts stays in lockstep with the evaluator',
expect(new Set(credentialSetupExpectationTexts(undefined))).toEqual(new Set(emitted));
});
});
// Which field carries the base URL is per-provider (`host` for gemini). The
// wrong one leaves the test pointed at the real provider, where it is discarded
// — so the check silently never runs for that provider.
describe('probeCredentialValue aims the test at the stand-in', () => {
const logger = createLogger(false);
const VERIFY_URL = 'http://dispatcher:41234';
function client(storedData: Record<string, unknown>) {
const testCredential = vi.fn().mockResolvedValue({ status: 'OK' });
return {
/** The `data` of each credential-test request the probe sent. */
tested: (): Array<Record<string, unknown>> =>
testCredential.mock.calls.map(([c]) => (c as { data: Record<string, unknown> }).data),
client: {
listCredentials: vi
.fn()
.mockResolvedValue([{ id: 'new1', name: 'acct', type: 'googlePalmApi' }]),
getCredentialForTest: vi
.fn()
.mockResolvedValue({ id: 'new1', name: 'acct', type: 'googlePalmApi', data: storedData }),
testCredential,
} as unknown as N8nClient,
};
}
it('overwrites the field the manifest names, not always `url`', async () => {
const { client: c, tested } = client({
host: 'https://generativelanguage.googleapis.com',
apiKey: '',
});
const probe = await probeCredentialValue({
client: c,
credentialType: 'googlePalmApi',
credentialIdsBefore: [],
fixture: { verifyAttempts: 1, verifiedOk: true },
verifyBaseUrl: VERIFY_URL,
urlField: 'host',
logger,
});
expect(probe).toEqual({ kind: 'passed', target: 'stand-in' });
expect(tested()[0].host).toBe(VERIFY_URL);
// And nothing invents a `url` the credential type does not have.
expect(tested()[0].url).toBeUndefined();
});
it('defaults to `url` when no field is named', async () => {
const { client: c, tested } = client({ url: 'https://api.anthropic.com', apiKey: '' });
await probeCredentialValue({
client: c,
credentialIdsBefore: [],
fixture: { verifyAttempts: 1, verifiedOk: true },
verifyBaseUrl: VERIFY_URL,
logger,
});
expect(tested()[0].url).toBe(VERIFY_URL);
});
it('leaves the URL alone in local mode, so the REAL provider answers', async () => {
const { client: c, tested } = client({
host: 'https://generativelanguage.googleapis.com',
apiKey: '',
});
const probe = await probeCredentialValue({
client: c,
credentialIdsBefore: [],
local: true,
urlField: 'host',
logger,
});
expect(probe).toEqual({ kind: 'passed', target: 'real' });
expect(tested()[0].host).toBe('https://generativelanguage.googleapis.com');
});
});

View File

@ -4,17 +4,21 @@
// 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 { jsonParse } from 'n8n-workflow';
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, rm, writeFile } 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 { findChromiumForEval, fixtureInterceptionArgs } from '../harness/browser-runtime';
import {
providerFixtureManifestSchema,
findFixtureForCredentialType,
loadProviderFixtures,
matchRoute,
MINT_PATH,
mintSecret,
startFixtureServer,
type FixtureServer,
@ -23,16 +27,157 @@ import { createLogger } from '../harness/logger';
const logger = createLogger(false);
/**
* Boot a fixture server + a browser aimed at it, and register the teardown.
*
* Uses `fixtureInterceptionArgs` — the same builder `startBrowserRuntime` uses —
* so these tests pin the interception setup the eval browser really gets,
* including the loopback EXCLUDE rules. Hand-assembled args drifted from it.
*/
function fixtureBrowser(credentialType: string) {
const handle: { server?: FixtureServer; ctx?: BrowserContext } = {};
let userDataDir: string | undefined;
beforeAll(async () => {
const fixture = await findFixtureForCredentialType(credentialType);
if (!fixture) throw new Error(`${credentialType} fixture missing`);
const server = await startFixtureServer({ fixture, logger });
handle.server = server;
userDataDir = await mkdtemp(join(tmpdir(), `fixture-test-${fixture.id}-`));
handle.ctx = await chromium.launchPersistentContext(userDataDir, {
executablePath: findChromiumForEval(),
headless: true,
args: fixtureInterceptionArgs(server.hostResolverRules(), undefined),
});
}, 60_000);
afterAll(async () => {
await handle.ctx?.close().catch(() => {});
await handle.server?.close();
if (userDataDir) await rm(userDataDir, { recursive: true, force: true });
});
// Non-null at use: every `it` runs after beforeAll, and a failed boot throws
// there rather than handing back a half-built handle.
return handle as { server: FixtureServer; ctx: BrowserContext };
}
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-');
// One row per shipped fixture. A hardcoded table still reds on a missing
// fixture, because findFixtureForCredentialType returns undefined for a row
// that has no directory.
const SHIPPED = [
{
type: 'anthropicApi',
id: 'anthropic',
host: 'platform.claude.com',
prefix: 'sk-ant-api03-',
verify: { path: '/v1/models', header: 'x-api-key' },
},
{
type: 'openAiApi',
id: 'openai',
host: 'platform.openai.com',
prefix: 'sk-proj-',
verify: { path: '/models', header: 'Authorization', scheme: 'Bearer' },
},
{
type: 'googlePalmApi',
id: 'gemini',
host: 'aistudio.google.com',
// Google's current shape, not the legacy `AIza…` one.
prefix: 'AQ.',
// Base URL lives in `host`, and the key travels as a query parameter.
urlField: 'host',
verify: { path: '/v1beta/models', query: 'key' },
},
// No `verify`: slackApi's test hardcodes its baseURL, so the stand-in can
// never be aimed at it. The value check reports itself unverifiable.
{ type: 'slackApi', id: 'slack', host: 'api.slack.com', prefix: 'xoxb-' },
];
it.each(SHIPPED)('ships the $id fixture keyed on $type', async (row) => {
const fixture = await findFixtureForCredentialType(row.type);
expect(fixture?.id).toBe(row.id);
expect(fixture?.manifest.hosts).toContain(row.host);
expect(fixture?.manifest.secretPrefix).toBe(row.prefix);
expect(fixture?.manifest.urlField).toBe(row.urlField ?? 'url');
expect(fixture?.manifest.verify).toEqual(row.verify);
});
// n8n serializes every credential type's own test request, so the manifest's
// transcription of it can be CHECKED rather than trusted. Without this the
// table above only pins what someone typed on the day, and an upstream change
// to `test.request` silently downgrades the value check to "unverifiable".
const TYPE_FILES = [
join(__dirname, '..', '..', '..', '..', 'nodes-base', 'dist', 'types', 'credentials.json'),
join(__dirname, '..', '..', '..', 'nodes-langchain', 'dist', 'types', 'credentials.json'),
];
it.skipIf(!TYPE_FILES.some(existsSync))(
"matches each credential's own test.request (needs built dist/types)",
async () => {
const types = new Map<string, { baseURL?: string; url?: string }>();
for (const file of TYPE_FILES.filter(existsSync)) {
for (const cred of jsonParse<
Array<{
name: string;
test?: { request?: { baseURL?: string; url?: string } };
}>
>(await readFile(file, 'utf8'))) {
if (cred.test?.request) types.set(cred.name, cred.test.request);
}
}
const fixtures = await loadProviderFixtures();
let checked = 0;
for (const fixture of fixtures) {
const request = types.get(fixture.manifest.credentialType);
if (!request?.baseURL) continue;
checked += 1;
const interpolated = /\{\{\s*\$credentials\??\.(\w+)\s*\}\}/.exec(request.baseURL);
if (!interpolated) {
// Hardcoded baseURL: there is no field to substitute, so a `verify`
// block could never be aimed at the stand-in.
expect(fixture.manifest.verify, `${fixture.id} verify`).toBeUndefined();
continue;
}
expect(fixture.manifest.urlField, `${fixture.id} urlField`).toBe(interpolated[1]);
const suffix = request.baseURL.slice(interpolated.index + interpolated[0].length);
expect(fixture.manifest.verify?.path, `${fixture.id} verify.path`).toBe(
`${suffix}${request.url ?? ''}`,
);
}
// Every shipped fixture must have been compared. Without this, a rename
// or a change to the serialized shape makes every iteration `continue`
// and the check passes having verified nothing.
expect(checked, 'fixtures cross-checked against their credential type').toBe(fixtures.length);
},
);
// The pages hardcode this path (they cannot import TS), and a rename would
// make every fixture's create button silently do nothing: the POST would fall
// through to the default page, which answers 200 with HTML, so `response.json()`
// throws inside the page and the run reds as agent misbehaviour.
it('serves the mint path every fixture page posts to', async () => {
for (const fixture of await loadProviderFixtures()) {
for (const file of Object.values(fixture.manifest.routes)) {
const html = await readFile(join(fixture.dir, file), 'utf8');
if (html.includes('__fixture__')) expect(html).toContain(MINT_PATH);
}
}
});
it('returns nothing for a credential type no fixture covers', async () => {
expect(await findFixtureForCredentialType('slackApi')).toBeUndefined();
expect(await findFixtureForCredentialType('notionApi')).toBeUndefined();
});
it('gives every fixture a credential type of its own', async () => {
// Selection is BY credential type, so a duplicate would depend on readdir order.
const types = (await loadProviderFixtures()).map((f) => f.manifest.credentialType);
expect(types).toEqual([...new Set(types)]);
});
it('declares a defaultRoute that is one of its own routes', async () => {
@ -42,6 +187,36 @@ describe('provider fixtures', () => {
});
});
describe('matchRoute', () => {
const routes = ['/apps', '/apps/:appId/general', '/apps/:appId/install-on-team'];
it('resolves a parameter segment to any value', () => {
expect(matchRoute(routes, '/apps/A0BQ7AWFNHM/general')).toBe('/apps/:appId/general');
expect(matchRoute(routes, '/apps/A0EVALAPP/install-on-team')).toBe(
'/apps/:appId/install-on-team',
);
});
it('prefers an exact route over a pattern that would also match', () => {
expect(matchRoute(['/apps/:appId/general', '/apps/fixed/general'], '/apps/fixed/general')).toBe(
'/apps/fixed/general',
);
});
it('does not match across segment boundaries', () => {
// Without this a console's whole URL space collapses onto one page, and the
// agent gets a plausible-looking wrong page instead of the default.
expect(matchRoute(routes, '/apps/A0BQ7AWFNHM/general/extra')).toBeUndefined();
expect(matchRoute(routes, '/apps/A0BQ7AWFNHM')).toBeUndefined();
expect(matchRoute(routes, '/apps/A0BQ7AWFNHM/oauth')).toBeUndefined();
});
it('still resolves plain routes', () => {
expect(matchRoute(routes, '/apps')).toBe('/apps');
expect(matchRoute(routes, '/nope')).toBeUndefined();
});
});
describe('mintSecret', () => {
it('carries the provider prefix and never repeats', () => {
const a = mintSecret('sk-ant-api03-');
@ -53,31 +228,10 @@ describe('mintSecret', () => {
});
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 });
});
const fx = fixtureBrowser('anthropicApi');
it('answers as the real hostname, so the agent never sees loopback', async () => {
const page = await ctx!.newPage();
const page = await fx.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');
@ -86,22 +240,22 @@ describe('fixture server served to a real browser', () => {
});
it('serves the default page for an unmodelled path instead of stranding the agent', async () => {
const page = await ctx!.newPage();
const page = await fx.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();
const page = await fx.ctx.newPage();
await page.goto('https://platform.claude.com/settings/keys');
expect(await page.content()).not.toContain(server.mintedSecret);
expect(server.secretWasIssued).toBe(false);
expect(await page.content()).not.toContain(fx.server.mintedSecret);
expect(fx.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();
const page = await fx.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
@ -112,31 +266,52 @@ describe('fixture server served to a real browser', () => {
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();
const add = page.getByRole('dialog').getByRole('button', { name: 'Add' });
// The real console disables Add until Expires is set (NODE-5755): a name
// alone is not enough, and clicking anyway does nothing.
expect(await add.isDisabled()).toBe(true);
// Click-driven, as in the real trace: this is a listbox, not a native select.
await page.getByRole('combobox', { name: 'Expires' }).click();
await page.getByRole('option', { name: '30 days' }).click();
expect(await add.isDisabled()).toBe(false);
await 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,
fx.server.mintedSecret,
);
expect(server.secretWasIssued).toBe(true);
expect(fx.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);
expect(fx.server.events.map((e) => e.path)).toContain('/settings/keys');
expect(fx.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);
expect(fx.server.events.some((e) => e.host === 'platform.claude.com')).toBe(true);
expect(fx.server.events.every((e) => e.host !== '127.0.0.1' && e.host !== 'localhost')).toBe(
true,
);
await page.close();
});
it('surfaces the disabled submit AS disabled, so the gap is diagnosable', async () => {
// Case 593 turns on this: the dead end has to be visible in the tree the
// agent reads, or the gap is undiagnosable rather than just open.
const page = await fx.ctx.newPage();
await page.goto('https://platform.claude.com/settings/keys');
await page.getByRole('button', { name: 'Create Key' }).click();
const outline = await page.getByRole('dialog').ariaSnapshot();
expect(outline).toMatch(/button "Add".*disabled/);
await page.close();
});
it('exposes an accessibility outline with the landmarks the agent needs', async () => {
const page = await ctx!.newPage();
const page = await fx.ctx.newPage();
await page.goto('https://platform.claude.com/settings/keys');
const outline = await page.locator('main').ariaSnapshot();
expect(outline).toContain('button "Create Key"');
@ -150,6 +325,255 @@ describe('fixture server served to a real browser', () => {
});
});
// Same contract as the anthropic block, against the other console. Not shared
// with it: the differences between the consoles are the point.
describe('openai fixture served to a real browser', () => {
const fx = fixtureBrowser('openAiApi');
it('answers as the real hostname, so the agent never sees loopback', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/api-keys');
expect(page.url()).toBe('https://platform.openai.com/api-keys');
expect(await page.locator('h1').textContent()).toBe('API keys');
await page.close();
});
it('keeps the two "create...key" buttons distinguishable', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/api-keys');
// Closed, the submit is not in the tree at all — only the opener is.
expect(await page.getByRole('button', { name: 'Create' }).count()).toBe(1);
await page.getByRole('button', { name: 'Create new secret key' }).click();
// Open, a loose match hits BOTH: that is the hazard, and the exact names
// have to keep them apart.
expect(await page.getByRole('button', { name: 'Create' }).count()).toBe(2);
expect(await page.getByRole('button', { name: 'Create new secret key' }).count()).toBe(1);
expect(await page.getByRole('button', { name: 'Create secret key', exact: true }).count()).toBe(
1,
);
await page.close();
});
it('hands out exactly the ledger secret through the create-key flow', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/home');
// Navigate the way the agent has to: the landing page is not the key page.
await page.getByRole('complementary').getByRole('link', { name: 'API Keys' }).click();
expect(await page.locator('h1').textContent()).toBe('API keys');
await page.getByRole('button', { name: 'Create new secret key' }).click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Create new secret key' });
// Name is optional here — the contrast with anthropic's gated Add.
expect(await dialog.getByRole('button', { name: 'Create secret key' }).isDisabled()).toBe(
false,
);
await page.getByPlaceholder('My Test Key').fill('n8n');
await dialog.getByRole('button', { name: 'Create secret key' }).click();
await page.waitForFunction("document.getElementById('key-value')?.value !== ''");
const reveal = page.getByRole('dialog').filter({ hasText: 'Save your key' });
expect(await reveal.getByRole('textbox').inputValue()).toBe(fx.server.mintedSecret);
expect(fx.server.secretWasIssued).toBe(true);
expect(fx.server.events.some((e) => e.mintedSecret)).toBe(true);
expect(fx.server.events.some((e) => e.host === 'platform.openai.com')).toBe(true);
expect(fx.server.events.every((e) => e.host !== '127.0.0.1' && e.host !== 'localhost')).toBe(
true,
);
await page.close();
});
it('shows the new key TRUNCATED in the list, never in full', async () => {
// Creates the key on THIS page: the assertion is about the row the create
// flow renders, and a fresh page never contains the secret in the first
// place, so asserting there proves nothing.
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/api-keys');
await page.getByRole('button', { name: 'Create new secret key' }).click();
await page
.getByRole('dialog')
.getByRole('button', { name: 'Create secret key', exact: true })
.click();
await page.waitForFunction("document.getElementById('key-value')?.value !== ''");
const outline = await page.locator('table').ariaSnapshot();
expect(outline).toMatch(/sk-\.\.\.\w{4}/);
expect(outline).not.toContain(fx.server.mintedSecret);
// Nor may a derived column smuggle a run of the key into the tree.
expect(outline).not.toContain(fx.server.mintedSecret.slice(-16));
await page.close();
});
it('renders a name containing markup as text, not as markup', async () => {
// The name is free-form agent input. Interpolated into the row's HTML it
// would corrupt the table the truncation assertions read.
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/api-keys');
await page.getByRole('button', { name: 'Create new secret key' }).click();
await page.getByPlaceholder('My Test Key').fill('<b>bold</b> & co');
await page
.getByRole('dialog')
.getByRole('button', { name: 'Create secret key', exact: true })
.click();
await page.waitForFunction("document.getElementById('key-value')?.value !== ''");
expect(await page.locator('#key-rows tr').first().textContent()).toContain('<b>bold</b> & co');
expect(await page.locator('#key-rows b').count()).toBe(0);
await page.close();
});
it('exposes an accessibility outline with the landmarks the agent needs', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/api-keys');
const outline = await page.locator('main').ariaSnapshot();
expect(outline).toContain('button "Create new secret key"');
expect(outline).toContain('heading "API keys"');
// Calibrated against the real capture.
for (const col of ['Name', 'Status', 'Tracking ID', 'Secret Key', 'Created', 'Permissions']) {
expect(outline).toContain(col);
}
await page.close();
});
});
// The awkward console: two blocking overlays, no role=dialog, key as text.
describe('gemini fixture served to a real browser', () => {
const fx = fixtureBrowser('googlePalmApi');
it('blocks the page behind BOTH overlays until each is dismissed', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://aistudio.google.com/apikey');
expect(await page.getByRole('button', { name: 'Create API key' }).isVisible()).toBe(false);
await page.getByRole('button', { name: 'Close guided tour' }).click();
expect(await page.getByRole('button', { name: 'Create API key' }).isVisible()).toBe(false);
await page.getByRole('button', { name: 'OK, got it' }).click();
expect(await page.getByRole('button', { name: 'Create API key' }).isVisible()).toBe(true);
await page.close();
});
async function openConsole() {
const page = await fx.ctx.newPage();
await page.goto('https://aistudio.google.com/apikey');
await page.getByRole('button', { name: 'Close guided tour' }).click();
await page.getByRole('button', { name: 'OK, got it' }).click();
return page;
}
it('hands out exactly the ledger secret, as TEXT rather than a field', async () => {
const page = await openConsole();
await page.getByRole('button', { name: 'Create API key' }).click();
await page.getByLabel('Name your key').fill('n8n');
await page.getByRole('button', { name: 'Create key', exact: true }).click();
await page.waitForFunction("document.getElementById('key-value')?.textContent !== ''");
// textContent, not inputValue — the key is a text node here.
expect(await page.locator('#key-value').textContent()).toBe(fx.server.mintedSecret);
expect(fx.server.mintedSecret.startsWith('AQ.')).toBe(true);
expect(fx.server.secretWasIssued).toBe(true);
expect(fx.server.events.some((e) => e.host === 'aistudio.google.com')).toBe(true);
await page.close();
});
it('keeps the reveal panel findable even though it is not a role=dialog', async () => {
const page = await openConsole();
expect(await page.getByRole('dialog').count()).toBe(0);
await page.getByRole('button', { name: 'Create API key' }).click();
expect(await page.getByRole('dialog').count()).toBe(0);
expect(await page.getByLabel('Create a new key Close dialog').count()).toBe(1);
await page.close();
});
it('renders a name containing markup as text, not as markup', async () => {
const page = await openConsole();
await page.getByRole('button', { name: 'Create API key' }).click();
await page.getByLabel('Name your key').fill('<i>italic</i> & co');
await page.getByRole('button', { name: 'Create key', exact: true }).click();
await page.waitForFunction("document.getElementById('key-value')?.textContent !== ''");
expect(await page.locator('#key-rows tr').first().textContent()).toContain(
'<i>italic</i> & co',
);
expect(await page.locator('#key-rows i').count()).toBe(0);
await page.close();
});
it('shows only the last four characters in the list', async () => {
// Mint on this page first — see the openai equivalent.
const page = await openConsole();
await page.getByRole('button', { name: 'Create API key' }).click();
await page.getByLabel('Name your key').fill('n8n');
await page.getByRole('button', { name: 'Create key', exact: true }).click();
await page.waitForFunction("document.getElementById('key-value')?.textContent !== ''");
const outline = await page.locator('table').ariaSnapshot();
expect(outline).toMatch(/button "\.\.\.\w{4}"/);
expect(outline).not.toContain(fx.server.mintedSecret);
await page.close();
});
});
// Multi-page, unlike the other three: the token only exists after installing.
describe('slack fixture served to a real browser', () => {
const fx = fixtureBrowser('slackApi');
it('keeps the manifest editor click-trapped but typeable', async () => {
// The live reproduction for case 599: the focusable input sits under the
// rendered surface, so a click never passes the hit-test while fill()
// does. Short timeout here — the real one costs 30s.
const page = await fx.ctx.newPage();
await page.goto('https://api.slack.com/apps');
await page.getByRole('button', { name: 'Create New App' }).click();
await page.getByRole('button', { name: 'From a manifest' }).click();
await expect(page.locator('#manifest-input').click({ timeout: 1_000 })).rejects.toThrow(
/Timeout|intercepts pointer events/,
);
await page.locator('#manifest-input').fill('{"display_information":{"name":"n8n"}}');
expect(await page.locator('#manifest-input').inputValue()).toContain('display_information');
await page.close();
});
it('leaves Next enabled — nothing here is gated on a required field', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://api.slack.com/apps');
await page.getByRole('button', { name: 'Create New App' }).click();
await page.getByRole('button', { name: 'From a manifest' }).click();
expect(await page.getByRole('button', { name: 'Next' }).isDisabled()).toBe(false);
await page.close();
});
it('issues the ledger token only after Install to Workspace', async () => {
const page = await fx.ctx.newPage();
// An app id this fixture has never seen — the console mints one per app, and
// the agent navigates by typing those URLs back.
await page.goto('https://api.slack.com/apps/A0Q3ZK71LMN/install-on-team');
expect(await page.content()).not.toContain(fx.server.mintedSecret);
await page.getByRole('button', { name: 'Install to Workspace' }).click();
await page.waitForFunction("document.getElementById('bot-token')?.value !== ''");
expect(await page.getByLabel('Bot User OAuth Token').inputValue()).toBe(fx.server.mintedSecret);
expect(fx.server.mintedSecret.startsWith('xoxb-')).toBe(true);
expect(fx.server.secretWasIssued).toBe(true);
await page.close();
});
it('reaches the token only through the multi-page flow', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://api.slack.com/apps/A0Q3ZK71LMN/general');
// Basic Information carries the OAuth2 credentials, never the bot token.
expect(await page.content()).not.toContain(fx.server.mintedSecret);
expect(await page.getByLabel('Client ID').inputValue()).toContain('.');
await page.getByRole('link', { name: 'Install App' }).click();
expect(await page.locator('h1').first().textContent()).toContain('OAuth Tokens');
// The id survives the hop rather than reverting to a hardcoded one.
expect(page.url()).toContain('/apps/A0Q3ZK71LMN/install-on-team');
await page.close();
});
});
describe('providerFixtureManifestSchema', () => {
const valid = {
credentialType: 'anthropicApi',
@ -192,4 +616,138 @@ describe('providerFixtureManifestSchema', () => {
it('rejects an empty hosts list', () => {
expect(providerFixtureManifestSchema.safeParse({ ...valid, hosts: [] }).success).toBe(false);
});
// The three providers agree on none of this — a manifest that could only
// describe anthropic left the other two's value check unverifiable.
it('accepts a query-param secret instead of a header', () => {
expect(
providerFixtureManifestSchema.safeParse({
...valid,
verify: { path: '/v1beta/models', query: 'key' },
}).success,
).toBe(true);
});
it('accepts a header scheme, for a credential that sends `Bearer <key>`', () => {
expect(
providerFixtureManifestSchema.safeParse({
...valid,
verify: { path: '/models', header: 'Authorization', scheme: 'Bearer' },
}).success,
).toBe(true);
});
it('rejects a verify block carrying neither a header nor a query param', () => {
// The union has no arm without one of them, so this is structural — there is
// no refinement message to assert, only that it cannot parse.
expect(
providerFixtureManifestSchema.safeParse({ ...valid, verify: { path: '/models' } }).success,
).toBe(false);
});
it('rejects a scheme on the query arm, where it could never be honoured', () => {
// `{path, query, scheme}` used to parse and then compare "Bearer <secret>"
// against a query value — a permanent 401 reported as unverifiable.
expect(
providerFixtureManifestSchema.safeParse({
...valid,
verify: { path: '/v1beta/models', query: 'key', scheme: 'Bearer' },
}).success,
).toBe(false);
});
it('rejects a verify block carrying both — the stand-in must know where to look', () => {
const res = providerFixtureManifestSchema.safeParse({
...valid,
verify: { path: '/models', header: 'x-api-key', query: 'key' },
});
expect(res.success).toBe(false);
});
it('accepts a urlField, for a credential whose base URL is not called `url`', () => {
const res = providerFixtureManifestSchema.safeParse({ ...valid, urlField: 'host' });
expect(res.success).toBe(true);
});
});
// The listener n8n's credential test talks to. Getting this wrong silently
// discards the strongest of the three deterministic checks.
describe('the provider stand-in accepts each provider shape', () => {
const SECRET = 'sk-test-0123456789abcdef';
let dir: string;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), 'fixture-verify-'));
await writeFile(
join(dir, 'console.html'),
'<!doctype html><html><body><h1>Keys</h1></body></html>',
);
});
afterAll(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
async function withServer(
verify: Record<string, string>,
run: (server: FixtureServer) => void | Promise<void>,
): Promise<void> {
const server = await startFixtureServer({
fixture: {
id: 'under-test',
dir,
manifest: providerFixtureManifestSchema.parse({
credentialType: 'x',
hosts: ['console.example.com'],
secretPrefix: 'sk-test-',
routes: { '/keys': 'console.html' },
defaultRoute: '/keys',
verify,
}),
},
logger,
secret: SECRET,
});
try {
await run(server);
} finally {
await server.close();
}
}
it('accepts a bare header value, and refuses a truncated one', async () => {
await withServer({ path: '/v1/models', header: 'x-api-key' }, async (server) => {
const url = `${server.verifyBaseUrl}/v1/models`;
expect((await fetch(url, { headers: { 'x-api-key': SECRET } })).status).toBe(200);
expect((await fetch(url, { headers: { 'x-api-key': SECRET.slice(0, 12) } })).status).toBe(
401,
);
expect(server.verifiedOk).toBe(true);
expect(server.verifyAttempts).toBe(2);
});
});
it('accepts a schemed header, so `Bearer <key>` is not read as a wrong key', async () => {
await withServer(
{ path: '/models', header: 'Authorization', scheme: 'Bearer' },
async (server) => {
const url = `${server.verifyBaseUrl}/models`;
expect((await fetch(url, { headers: { Authorization: `Bearer ${SECRET}` } })).status).toBe(
200,
);
// The bare key is the WRONG presentation for this credential type.
expect((await fetch(url, { headers: { Authorization: SECRET } })).status).toBe(401);
},
);
});
it('accepts a query-param secret, which never reaches a header at all', async () => {
await withServer({ path: '/v1beta/models', query: 'key' }, async (server) => {
const base = `${server.verifyBaseUrl}/v1beta/models`;
expect((await fetch(`${base}?key=${SECRET}`)).status).toBe(200);
expect((await fetch(`${base}?key=nope`)).status).toBe(401);
// A query-param provider must not be satisfiable by a header.
expect((await fetch(base, { headers: { 'x-api-key': SECRET } })).status).toBe(401);
});
});
});

View File

@ -5,7 +5,12 @@
import { describe, it, expect } from 'vitest';
import { fixtureInterceptionArgs, planRelayConnection } from '../harness/browser-runtime';
import {
browserN8nOrigins,
browserSessionCookie,
fixtureInterceptionArgs,
planRelayConnection,
} from '../harness/browser-runtime';
const EXT = 'chrome-extension://cegmdpndekdfpnafgacidejijecomlhh/connect.html';
@ -192,3 +197,44 @@ describe('fixtureInterceptionArgs', () => {
expect(rules).toContain('MAP localhost:5680 n8n:5678');
});
});
// The session cookie the launched browser is given. Same subject as the rest of
// this file: which spelling of n8n the BROWSER ends up using, after the rewrite
// above. Get it wrong and the credential-setup flow dead-ends on /signin with
// every provider-console expectation failing as though the agent misbehaved.
describe('browserN8nOrigins', () => {
it('returns the single origin when n8n is already loopback', () => {
expect(browserN8nOrigins('http://localhost:5678')).toEqual(['http://localhost:5678']);
});
it('adds the localhost origin the relay MAP rule creates for a non-loopback n8n', () => {
// planRelayConnection rewrites a non-loopback n8n to localhost + a MAP rule,
// so in the split-container topology the browser knows n8n by THAT origin —
// a cookie on `http://n8n:5678` alone would never be sent.
expect(browserN8nOrigins('http://n8n:5678')).toEqual([
'http://n8n:5678',
'http://localhost:5678',
]);
});
});
describe('browserSessionCookie', () => {
it('turns the client cookie header into a cookie for the browser context', () => {
expect(browserSessionCookie('n8n-auth=abc123', 'http://localhost:5678')).toEqual({
name: 'n8n-auth',
value: 'abc123',
url: 'http://localhost:5678',
});
});
it('splits on the FIRST = only, so a base64/JWT value survives intact', () => {
const value = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0=';
expect(browserSessionCookie(`n8n-auth=${value}`, 'http://n8n:5678')?.value).toBe(value);
});
it('returns undefined for anything that is not a name=value pair', () => {
expect(browserSessionCookie('', 'http://localhost:5678')).toBeUndefined();
expect(browserSessionCookie('n8n-auth', 'http://localhost:5678')).toBeUndefined();
expect(browserSessionCookie('=orphan', 'http://localhost:5678')).toBeUndefined();
});
});

View File

@ -14,13 +14,16 @@
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:
Three properties that are load-bearing, all 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.
3. "Add" is DISABLED until Expires has a value, and Expires opens unselected —
the real dialog's own rule, and the required-field gap NODE-5755 tracks.
Defaulting it to "Never" tested past the bug.
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
@ -95,13 +98,26 @@
</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>
<!-- A custom dropdown, not a native <select>: in the real trace the
agent set this by CLICKING items. A native <option> cannot be
clicked, which would invent a dead end the real console lacks. -->
<span id="expires-label">Expires</span>
<button
id="expires-trigger"
type="button"
role="combobox"
aria-labelledby="expires-label"
aria-expanded="false"
aria-controls="expires-list"
>
Select an option
</button>
<ul id="expires-list" role="listbox" aria-labelledby="expires-label" hidden>
<li role="option" data-value="never">Never</li>
<li role="option" data-value="30d">30 days</li>
<li role="option" data-value="90d">90 days</li>
</ul>
<button id="create-submit" type="button" disabled>Add</button>
</div>
<!-- Reveal dialog — the ONLY place the full key ever appears. -->
@ -120,6 +136,32 @@
const reveal = document.getElementById('reveal');
const keyValue = document.getElementById('key-value');
const nameInput = document.getElementById('key-name');
const expiresTrigger = document.getElementById('expires-trigger');
const expiresList = document.getElementById('expires-list');
const submit = document.getElementById('create-submit');
let expiresValue = '';
expiresTrigger.addEventListener('click', () => {
const willOpen = expiresList.hidden;
expiresList.hidden = !willOpen;
expiresTrigger.setAttribute('aria-expanded', String(willOpen));
});
// Add stays disabled until Expires has a value — the dead end the agent
// has to diagnose.
const options = [...expiresList.querySelectorAll('[role="option"]')];
for (const option of options) {
option.addEventListener('click', () => {
expiresValue = option.dataset.value;
expiresTrigger.textContent = option.textContent;
for (const other of options) {
other.setAttribute('aria-selected', String(other === option));
}
expiresList.hidden = true;
expiresTrigger.setAttribute('aria-expanded', 'false');
submit.disabled = false;
});
}
document.getElementById('create-key').addEventListener('click', () => {
dialog.hidden = false;
@ -135,7 +177,7 @@
body: JSON.stringify({
name: nameInput.value || 'Untitled',
workspace: document.getElementById('workspace').value,
expires: document.getElementById('expires').value,
expires: expiresValue,
}),
});
const { key } = await response.json();
@ -152,7 +194,9 @@
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><td>Eval Owner</td><td>just now</td><td>' +
expiresTrigger.textContent +
'</td><td>—</td>' +
'<td><button type="button">Delete</button></td>';
document.getElementById('key-rows').prepend(row);
const count = document.getElementById('key-count');

View File

@ -0,0 +1,218 @@
<!doctype html>
<!--
Lookalike Google AI Studio API-keys console. Our own generic content — no
provider source, no recorded responses, no real tokens. Calibrated against a
real captured accessibility tree (LangTracer thread 708172f1, 2026-08-17).
Load-bearing, all taken from the real page:
- TWO overlays (guided tour, cookie notice) block the page until dismissed
- the key is a TEXT NODE with a copy button, not a field, so reading
`input.value` finds nothing here
- neither panel is role=dialog — they are containers with accessible names
- the list truncates to the last four characters, rendered as a button
- keys carry Google's current `AQ.` prefix, and minting the same shape is what
lets the hermetic case notice a partially captured value (NODE-5768) instead
of passing on a friendlier format.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>API Keys | Google AI Studio</title>
</head>
<body>
<div id="tour" role="dialog" aria-label="Guided Tour" aria-modal="true">
<h2>credit_card New: Control your API cost</h2>
<p>Set a monthly spend limit per project and get alerts before you hit it.</p>
<a href="/docs/billing">Further resources</a>
<button id="tour-close" type="button">Close guided tour</button>
</div>
<div id="cookies">
<p>Google AI Studio uses cookies to deliver and improve the service.</p>
<a href="/docs/cookies">Learn more about how Google uses cookies. Opens in a new tab.</a>
<button id="cookies-ok" type="button">OK, got it</button>
</div>
<div id="page" hidden>
<button type="button">Skip to main content</button>
<header>
<button type="button">Toggle navigation menu</button>
<a href="/">Google AI Studio logo</a>
<button type="button">View related products</button>
<nav aria-label="Main">
<ul>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/apikey" aria-current="page">API Keys</a></li>
<li><a href="/projects">Projects</a></li>
<li><a href="/usage">Usage</a></li>
<li><a href="/rate-limit">Rate Limit</a></li>
<li><a href="/spend">Spend</a></li>
<li><a href="/billing">Billing</a></li>
<li><a href="/logs">Logs and Datasets</a></li>
<li><a href="/changelog">Changelog</a></li>
</ul>
</nav>
<button type="button">What's new</button>
<button type="button">Settings</button>
<button type="button">Search</button>
<button type="button">Get API key</button>
<button type="button">Google Account: Eval Owner</button>
</header>
<main>
<h2>API Keys</h2>
<a href="/docs/quickstart">API quickstart</a>
<button id="open-create" type="button">Create API key</button>
<span>Group by</span>
<button type="button">API Key</button>
<button type="button">Project</button>
<span>Filter by</span>
<label for="project-filter">Filter by</label>
<select id="project-filter" name="project-filter">
<option value="all" selected>All projects</option>
<option value="default">Default Gemini Project</option>
</select>
<table>
<thead>
<tr>
<th scope="col">Key</th>
<th scope="col">Project</th>
<th scope="col"><button type="button">Created</button></th>
<th scope="col">Billing Tier</th>
<th scope="col"></th>
</tr>
</thead>
<tbody id="key-rows">
<tr>
<td><button type="button">...iIJg</button><span>Test local model selection</span></td>
<td><button type="button">Default Gemini Project</button><span>gen-lang-client-0228122468</span></td>
<td>Jul 8, 2026</td>
<td><button type="button">Set up billing</button><span>Free tier</span></td>
<td>
<button type="button">Copy API key</button>
<button type="button">View spend</button>
<button type="button">View usage</button>
<button type="button">View more actions</button>
</td>
</tr>
</tbody>
</table>
<p>Can't find your API keys here?</p>
<p>
This list only shows API keys for projects imported into Google AI
Studio. Import other projects to manage their associated API Keys. You
can also create a new API Key above.
<a href="/docs/api-key#import-projects">Learn more</a>
</p>
<button type="button">Import projects</button>
<!-- Not role=dialog — the real one isn't either. -->
<div id="create-panel" aria-label="Create a new key Close dialog" hidden>
<h2>Create a new key <button id="create-close" type="button">Close dialog</button></h2>
<label for="key-name">Name your key</label>
<input id="key-name" name="key-name" type="text" placeholder="Name your key" value="" />
<label for="key-project">Project</label>
<select id="key-project" name="key-project">
<option value="default" selected>Default Gemini Project</option>
<option value="new">Create a new project</option>
</select>
<button id="create-cancel" type="button">Cancel</button>
<button id="create-submit" type="button">Create key</button>
</div>
<div id="reveal" aria-label="API key details close" hidden>
<h2>API key details <button id="reveal-close" type="button">close</button></h2>
<p>
info For details on using auth keys with Gemini API, see our
<a href="/docs/api-key">API key documentation</a>.
</p>
<span>API Key</span>
<span id="key-value"></span>
<button id="copy-key" type="button">Copy key to clipboard</button>
<span>Name</span>
<span id="reveal-name">Gemini API Key</span>
<button type="button">Copy display name to clipboard</button>
<span>Project name</span>
<span>projects/58324130107</span>
<button type="button">Copy project name to clipboard</button>
<span>Project number</span>
<span>58324130107</span>
</div>
</main>
</div>
<script>
const tour = document.getElementById('tour');
const cookies = document.getElementById('cookies');
const page = document.getElementById('page');
const panel = document.getElementById('create-panel');
const reveal = document.getElementById('reveal');
const keyValue = document.getElementById('key-value');
const nameInput = document.getElementById('key-name');
// Hidden until BOTH overlays are dismissed.
const revealPage = () => {
if (tour.hidden && cookies.hidden) page.hidden = false;
};
document.getElementById('tour-close').addEventListener('click', () => {
tour.hidden = true;
revealPage();
});
document.getElementById('cookies-ok').addEventListener('click', () => {
cookies.hidden = true;
revealPage();
});
document.getElementById('open-create').addEventListener('click', () => {
panel.hidden = false;
nameInput.focus();
});
document.getElementById('create-cancel').addEventListener('click', () => {
panel.hidden = true;
});
document.getElementById('create-close').addEventListener('click', () => {
panel.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 || 'Gemini API Key',
project: document.getElementById('key-project').value,
}),
});
const { key } = await response.json();
keyValue.textContent = key;
document.getElementById('reveal-name').textContent = nameInput.value || 'Gemini API Key';
panel.hidden = true;
reveal.hidden = false;
const row = document.createElement('tr');
row.innerHTML =
'<td><button type="button">...' +
key.slice(-4) +
'</button><span class="key-name"></span></td>' +
'<td><button type="button">Default Gemini Project</button><span>gen-lang-client-0228122468</span></td>' +
'<td>just now</td>' +
'<td><button type="button">Set up billing</button><span>Free tier</span></td>' +
'<td><button type="button">Copy API key</button><button type="button">View spend</button>' +
'<button type="button">View usage</button><button type="button">View more actions</button></td>';
// textContent, not markup — see the openai fixture.
row.querySelector('.key-name').textContent = nameInput.value || 'Gemini API Key';
document.getElementById('key-rows').prepend(row);
});
document.getElementById('copy-key').addEventListener('click', async () => {
await navigator.clipboard.writeText(keyValue.textContent).catch(() => {});
});
document.getElementById('reveal-close').addEventListener('click', () => {
reveal.hidden = true;
});
</script>
</body>
</html>

View File

@ -0,0 +1,42 @@
<!doctype html>
<!--
Lookalike AI Studio landing page, and this fixture's defaultRoute. Exists so
the agent has to NAVIGATE to the key page rather than starting on it.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Google AI Studio</title>
</head>
<body>
<header>
<button type="button">Toggle navigation menu</button>
<a href="/">Google AI Studio logo</a>
<nav aria-label="Main">
<ul>
<li><a href="/dashboard" aria-current="page">Dashboard</a></li>
<li><a href="/apikey">API Keys</a></li>
<li><a href="/projects">Projects</a></li>
<li><a href="/usage">Usage</a></li>
<li><a href="/billing">Billing</a></li>
</ul>
</nav>
<button type="button">Get API key</button>
<button type="button">Google Account: Eval Owner</button>
</header>
<main>
<h1>Dashboard</h1>
<section aria-labelledby="get-started">
<h2 id="get-started">Get started</h2>
<p>
Create an API key to call the Gemini API. Manage keys from
<a href="/apikey">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>

View File

@ -0,0 +1,15 @@
{
"credentialType": "googlePalmApi",
"hosts": ["aistudio.google.com"],
"secretPrefix": "AQ.",
"routes": {
"/apikey": "console.html",
"/dashboard": "dashboard.html"
},
"defaultRoute": "/dashboard",
"urlField": "host",
"verify": {
"path": "/v1beta/models",
"query": "key"
}
}

View File

@ -0,0 +1,184 @@
<!doctype html>
<!--
Lookalike OpenAI API-keys console. Our own generic content — no provider
source, no recorded responses, no real tokens. Calibrated against a real
captured accessibility tree (LangTracer thread 5dc90eeb, 2026-08-17).
Load-bearing, all taken from the real page:
- keys are listed TRUNCATED (`sk-...1fIA`), so a lifted display value is far
too short to be the key — the exact-value check catches it
- the submit is "Create secret key"; "Create new secret key" only OPENS the
dialog. One word apart, so a loose locator hits the wrong control
- Name is optional and nothing gates the submit — the contrast with the
anthropic fixture, whose Add is disabled until Expires is set
- the reveal textbox has no accessible name
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>API keys - OpenAI Platform</title>
</head>
<body>
<aside aria-label="Main navigation">
<button type="button">Default project</button>
<button type="button">Collapse sidebar</button>
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/chat/edit">Chat</a></li>
<li><a href="/audio">Audio</a></li>
<li><a href="/playground/images">Images</a></li>
<li><a href="/codex">Codex</a></li>
<li><a href="/api-keys" aria-current="page">API Keys</a></li>
<li><a href="/usage">Usage</a></li>
<li><a href="/logs">Logs</a></li>
<li><a href="/batches">Batches</a></li>
<li><a href="/storage">Storage</a></li>
<li><a href="/plugins">Plugins</a></li>
<li><a href="/settings/proj_evalowner">Settings</a></li>
</ul>
<button type="button">More navigation items</button>
<button type="button">P Personal Organization</button>
</aside>
<main>
<h1>API keys</h1>
<button type="button">API key security guidance</button>
<a href="/usage">API Key Usage</a>
<button id="open-create" type="button">Create new secret key</button>
<label for="search-keys">Search API keys</label>
<input id="search-keys" name="search-keys" type="search" />
<button type="button">Active Clear current value</button>
<button type="button">Add filter</button>
<span id="result-count">4 results</span>
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Status</th>
<th scope="col">Tracking ID</th>
<th scope="col">Secret Key</th>
<th scope="col">Created</th>
<th scope="col">Last used</th>
<th scope="col">Created by</th>
<th scope="col">Permissions</th>
<th scope="col">Monthly spend</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="key-rows">
<tr><td>service-instance</td><td>Active</td><td>key_Ejpr6NwBvEetPhCu</td><td>sk-...1fIA</td><td>Jun 24, 2026</td><td>Aug 17, 2026</td><td>Eval Owner</td><td>All</td><td>$0.00</td><td><button type="button">Edit</button><button type="button">Delete</button></td></tr>
<tr><td>local-testing</td><td>Active</td><td>key_KWxC7YgCSTMQW5S8</td><td>sk-...PGQA</td><td>Jun 23, 2026</td><td>Jul 24, 2026</td><td>Eval Owner</td><td>All</td><td>$0.00</td><td><button type="button">Edit</button><button type="button">Delete</button></td></tr>
<tr><td>batch-jobs</td><td>Active</td><td>key_X9mefkdO29ZUKROv</td><td>sk-...38YA</td><td>Feb 19, 2026</td><td>Feb 19, 2026</td><td>Eval Owner</td><td>All</td><td>$0.00</td><td><button type="button">Edit</button><button type="button">Delete</button></td></tr>
<tr><td>prototype</td><td>Active</td><td>key_GH4PD8W0d8oDAVGQ</td><td>sk-...5akA</td><td>Feb 10, 2026</td><td>Feb 10, 2026</td><td>Eval Owner</td><td>All</td><td>$0.00</td><td><button type="button">Edit</button><button type="button">Delete</button></td></tr>
</tbody>
</table>
<!-- Submit is "Create secret key", not the opener. -->
<div id="create-dialog" role="dialog" aria-labelledby="create-title" aria-modal="true" hidden>
<h2 id="create-title">Create new secret key</h2>
<span>Owned by</span>
<div role="radiogroup" aria-label="API key owner">
<label><input type="radio" name="owner" value="you" checked /> You</label>
<label><input type="radio" name="owner" value="service-account" /> Service account</label>
</div>
<p>
This API key is tied to your user and can make requests against the
selected project. If you are removed from the organization or project,
this key will be disabled.
</p>
<span>Name</span>
<span>Optional</span>
<input id="key-name" name="key-name" type="text" placeholder="My Test Key" value="" />
<span>Project</span>
<button type="button">Default project</button>
<span>Permissions</span>
<div role="radiogroup" aria-label="Permissions">
<label><input type="radio" name="permissions" value="all" checked /> All</label>
<label><input type="radio" name="permissions" value="restricted" /> Restricted</label>
<label><input type="radio" name="permissions" value="read-only" /> Read only</label>
</div>
<button id="create-cancel" type="button">Cancel</button>
<button id="create-submit" type="button">Create secret key</button>
</div>
<!-- The only place the full key appears; the textbox has no name. -->
<div id="reveal" role="dialog" aria-labelledby="reveal-title" aria-modal="true" hidden>
<h2 id="reveal-title">Save your key</h2>
<p>
Please save your secret key in a safe place since
<strong>you won't be able to view it again</strong>. Keep it secure, as
anyone with your API key can make requests on your behalf. If you do
lose it, you'll need to generate a new one.
</p>
<a href="/docs/api-key-best-practices">Learn more about API key best practices</a>
<input id="key-value" name="key-value" type="text" readonly value="" />
<button id="copy-key" type="button">Copy</button>
<span>Permissions</span>
<span>Read and write API resources</span>
<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('open-create').addEventListener('click', () => {
dialog.hidden = false;
nameInput.focus();
});
document.getElementById('create-cancel').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 || 'Secret key',
owner: document.querySelector('input[name="owner"]:checked').value,
permissions: document.querySelector('input[name="permissions"]:checked').value,
}),
});
const { key } = await response.json();
keyValue.value = key;
dialog.hidden = true;
reveal.hidden = false;
// 3 leading chars + last 4, as the real console truncates.
const row = document.createElement('tr');
row.innerHTML =
// Tracking id independent of the key, as the real console's is — a
// value derived from it would put a run of the key in the page for
// no reason.
'<td class="key-name"></td><td>Active</td><td>key_' +
Math.random().toString(36).slice(2, 18) +
'</td><td>' +
key.slice(0, 3) +
'...' +
key.slice(-4) +
'</td><td>just now</td><td>—</td><td>Eval Owner</td><td>All</td><td>$0.00</td>' +
'<td><button type="button">Edit</button><button type="button">Delete</button></td>';
// textContent, not markup: the name is free-form agent input, and a
// value containing `<` or `&` would otherwise corrupt the row the
// truncation assertions read.
row.querySelector('.key-name').textContent = nameInput.value || 'Secret key';
document.getElementById('key-rows').prepend(row);
const count = document.getElementById('result-count');
count.textContent = `${Number(count.textContent.split(' ')[0]) + 1} results`;
});
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>

View File

@ -0,0 +1,45 @@
<!doctype html>
<!--
Lookalike OpenAI landing page. Exists so the agent has to NAVIGATE to the keys
page rather than starting on it — reaching the right page is the step that
broke in NODE-5384.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Home - OpenAI Platform</title>
</head>
<body>
<aside aria-label="Main navigation">
<button type="button">Default project</button>
<ul>
<li><a href="/home" aria-current="page">Home</a></li>
<li><a href="/chat/edit">Chat</a></li>
<li><a href="/audio">Audio</a></li>
<li><a href="/playground/images">Images</a></li>
<li><a href="/codex">Codex</a></li>
<li><a href="/api-keys">API Keys</a></li>
<li><a href="/usage">Usage</a></li>
<li><a href="/logs">Logs</a></li>
<li><a href="/batches">Batches</a></li>
<li><a href="/storage">Storage</a></li>
<li><a href="/plugins">Plugins</a></li>
</ul>
<button type="button">P Personal Organization</button>
</aside>
<main>
<h1>Home</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="/api-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>

View File

@ -0,0 +1,15 @@
{
"credentialType": "openAiApi",
"hosts": ["platform.openai.com"],
"secretPrefix": "sk-proj-",
"routes": {
"/api-keys": "console.html",
"/home": "dashboard.html"
},
"defaultRoute": "/home",
"verify": {
"path": "/models",
"header": "Authorization",
"scheme": "Bearer"
}
}

View File

@ -0,0 +1,148 @@
<!doctype html>
<!--
Lookalike Slack app console (Your Apps + create-from-manifest). Our own generic
content — no provider source, no recorded responses, no real tokens.
Calibrated against a real captured accessibility tree (LangTracer thread
eca9cd8c, 2026-08-17).
Load-bearing, all taken from the real page:
- getting a credential is MULTI-PAGE: create an app here, then Install App,
and only the install page reveals a token
- the app id is MINTED on create and carried in every later URL, so the fixture
routes on `/apps/:appId/...` rather than a hardcoded id
- the manifest editor's focusable input sits UNDER the rendered surface, so a
click on it times out while typing into it works. That is the real
CodeMirror behaviour and the live reproduction for LangTracer case 599 —
replacing it with a plain visible textarea deletes the trap
- "Next" is ENABLED throughout. The real run assumed it was disabled and spent
three browser_evaluate scripts on that theory; it never was (not NODE-5755)
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Slack API: Applications | Slack</title>
</head>
<body>
<header>
<a href="/">Slack API</a>
<a href="/changelog">Changelog</a>
<a href="/docs">Documentation</a>
<a href="/tutorials">Tutorials</a>
<a href="/apps">Your Apps</a>
</header>
<main>
<button id="create-new-app" type="button">Create New App</button>
<h1>Your Apps</h1>
<label for="filter-apps">Filter apps by name or workspace</label>
<input id="filter-apps" name="filter-apps" type="text" />
<ul id="app-list">
<li><a href="/apps/A0EVALOLD/general">eval workspace bot</a><span>Eval Workspace</span></li>
<li><a href="/apps/A0EVALOL2/general">notes relay</a><span>Eval Workspace</span></li>
</ul>
<h4>Your App Configuration Tokens</h4>
<a href="/docs/tokens">Learn about tokens</a>
<button type="button">Generate Token</button>
<a href="/signin">Sign in to another workspace</a>
<!-- Chooser shown by "Create New App". -->
<div id="choose-dialog" role="alertdialog" aria-labelledby="choose-title" hidden>
<h1 id="choose-title">Create an app</h1>
<button id="from-manifest" type="button">From a manifest</button>
<button id="from-scratch" type="button">From scratch</button>
<button id="choose-close" type="button">Close</button>
</div>
<div id="manifest-dialog" role="alertdialog" aria-labelledby="manifest-title" hidden>
<h1 id="manifest-title">Create from a manifest</h1>
<p><strong>Manifest</strong></p>
<p>
This is your app’s manifest containing basic info, scopes, settings, and
features.
<a href="https://docs.slack.dev/app-manifests/configuring-apps-with-app-manifests/">Learn more about manifests</a>
or <a href="https://docs.slack.dev/samples/">check out examples</a>.
</p>
<p id="manifest-error" hidden>We can’t translate a manifest with errors.</p>
<p>
Add scopes so your app can be installed when you create it.
<a href="https://docs.slack.dev/reference/scopes/">Learn more about scopes</a>
</p>
<div role="tablist" aria-label="Manifest format">
<button role="tab" aria-selected="true" type="button">JSON</button>
<button role="tab" aria-selected="false" type="button">YAML</button>
</div>
<div role="tabpanel" aria-label="JSON">
<!-- The trap: the textarea is covered by the rendered surface, so a
click never passes the hit-test while fill() still works. -->
<div style="position: relative; height: 140px">
<textarea
id="manifest-input"
style="position: absolute; top: 0; left: 0; width: 100%; height: 140px"
></textarea>
<div
id="manifest-view"
style="position: absolute; top: 0; left: 0; width: 100%; height: 140px; background: #fff"
>
<span>1</span> <span>{}</span>
</div>
</div>
</div>
<button id="manifest-cancel" type="button">Cancel</button>
<button id="manifest-next" data-qa="new_app_modal_go" type="button">Next</button>
</div>
</main>
<script>
const chooser = document.getElementById('choose-dialog');
const dialog = document.getElementById('manifest-dialog');
const input = document.getElementById('manifest-input');
const view = document.getElementById('manifest-view');
const error = document.getElementById('manifest-error');
document.getElementById('create-new-app').addEventListener('click', () => {
chooser.hidden = false;
});
document.getElementById('choose-close').addEventListener('click', () => {
chooser.hidden = true;
});
document.getElementById('from-scratch').addEventListener('click', () => {
chooser.hidden = true;
dialog.hidden = false;
});
document.getElementById('from-manifest').addEventListener('click', () => {
chooser.hidden = true;
dialog.hidden = false;
});
// Mirror typed text into the rendered surface, as the editor does.
input.addEventListener('input', () => {
const lines = input.value.split('\n');
view.innerHTML = lines
.map((line, i) => '<span>' + (i + 1) + '</span> <span>' + line.replace(/</g, '&lt;') + '</span>')
.join('<br />');
});
document.getElementById('manifest-cancel').addEventListener('click', () => {
dialog.hidden = true;
});
// Enabled throughout, like the real one; it just refuses empty/invalid JSON.
document.getElementById('manifest-next').addEventListener('click', () => {
try {
JSON.parse(input.value);
} catch {
error.hidden = false;
return;
}
// The real console mints the id here and puts it in every later URL;
// the agent then navigates by typing those URLs back.
const appId = 'A0' + Math.random().toString(36).slice(2, 11).toUpperCase();
window.location.href = '/apps/' + appId + '/general';
});
</script>
</body>
</html>

View File

@ -0,0 +1,67 @@
<!doctype html>
<!--
Lookalike Slack app "Basic Information" page. Calibrated against a real
captured tree (LangTracer thread eca9cd8c, 2026-08-17).
The credentials shown HERE (client id/secret) drive the OAuth2 path, which
cannot complete hermetically: the authorize redirect and the token exchange
both leave the browser. The reachable hermetic path is the bot token on
Install App, so the sidebar link to it is the one that matters.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Basic Information | n8n integration | Slack</title>
</head>
<body>
<nav aria-label="App settings">
<span>n8n integration</span>
<!-- Only routed pages are linked. An unrouted link falls through to
defaultRoute and serves the app list, which looks like a working page
and is the trap `matchRoute` exists to avoid. -->
<h4>Settings</h4>
<ul>
<li><a href="/apps/A0EVALAPP/general" aria-current="page">Basic Information</a></li>
<li><a href="/apps/A0EVALAPP/install-on-team">Install App</a></li>
</ul>
<h4>Features</h4>
<ul>
<li><a href="/apps/A0EVALAPP/oauth">OAuth &amp; Permissions</a></li>
</ul>
</nav>
<main>
<h1>Basic Information</h1>
<h2>App Credentials</h2>
<p>These credentials allow your app to access the Slack API.</p>
<label for="client-id">Client ID</label>
<input id="client-id" name="client-id" type="text" readonly value="553213193971.11823370532599" />
<label for="client-secret">Client Secret</label>
<input id="client-secret" name="client-secret" type="password" readonly value="8f2c41a7d9b64e05ac13ff721b0d6e94" />
<button type="button">Show</button>
<button type="button">Regenerate</button>
<label for="signing-secret">Signing Secret</label>
<input id="signing-secret" name="signing-secret" type="password" readonly value="4d61b0e7c2a94f83bb75e10c9f3a2d68" />
<button type="button">Show</button>
<button type="button">Regenerate</button>
<h2>Display Information</h2>
<p>n8n integration</p>
<button type="button" disabled>Discard Changes</button>
<button type="button" disabled>Save Changes</button>
</main>
<script>
// Keep every in-app link on the id this page was reached with.
const appId = window.location.pathname.split('/')[2];
for (const a of document.querySelectorAll('a[href^="/apps/"]')) {
a.setAttribute('href', a.getAttribute('href').replace(/^\/apps\/[^/]+/, '/apps/' + appId));
}
</script>
</body>
</html>

View File

@ -0,0 +1,85 @@
<!doctype html>
<!--
Lookalike Slack "Install App" page — the hermetic path to a credential.
The token only exists after Install to Workspace, and it is rendered in a
field LABELLED as a token, which is what gets it redacted in the snapshot the
agent reads (the same treatment the real console's Client Secret got). So the
agent captures it through the marker, never by reading a raw value.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Install App | n8n integration | Slack</title>
</head>
<body>
<nav aria-label="App settings">
<span>n8n integration</span>
<h4>Settings</h4>
<ul>
<li><a href="/apps/A0EVALAPP/general">Basic Information</a></li>
<li><a href="/apps/A0EVALAPP/install-on-team" aria-current="page">Install App</a></li>
</ul>
</nav>
<main>
<h1>OAuth Tokens for Your Workspace</h1>
<p>
These tokens were automatically generated when you installed the app to your
team. You can use these to authenticate your app.
</p>
<section id="before-install">
<p>You must install this app to your workspace before a token is issued.</p>
<h2>Bot Token Scopes</h2>
<ul>
<li>channels:read</li>
<li>chat:write</li>
<li>groups:read</li>
<li>im:history</li>
</ul>
<button id="install" type="button">Install to Workspace</button>
</section>
<section id="after-install" hidden>
<h2>OAuth Tokens for Your Workspace</h2>
<label for="bot-token">Bot User OAuth Token</label>
<input id="bot-token" name="bot-token" type="text" readonly value="" />
<button id="copy-token" type="button">Copy</button>
<p>Installed to <strong>Eval Workspace</strong> by Eval Owner.</p>
<button type="button">Reinstall to Workspace</button>
</section>
</main>
<script>
// Keep every in-app link on the id this page was reached with.
const appId = window.location.pathname.split('/')[2];
for (const a of document.querySelectorAll('a[href^="/apps/"]')) {
a.setAttribute('href', a.getAttribute('href').replace(/^\/apps\/[^/]+/, '/apps/' + appId));
}
const before = document.getElementById('before-install');
const after = document.getElementById('after-install');
const token = document.getElementById('bot-token');
document.getElementById('install').addEventListener('click', async () => {
// The real console routes through an authorize screen; hermetically the
// grant is implicit, since the redirect would leave the fixture.
const response = await fetch('/__fixture__/create-key', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ app: 'n8n integration', workspace: 'Eval Workspace' }),
});
const { key } = await response.json();
token.value = key;
before.hidden = true;
after.hidden = false;
});
document.getElementById('copy-token').addEventListener('click', async () => {
await navigator.clipboard.writeText(token.value).catch(() => {});
});
</script>
</body>
</html>

View File

@ -0,0 +1,12 @@
{
"credentialType": "slackApi",
"hosts": ["api.slack.com"],
"secretPrefix": "xoxb-",
"routes": {
"/apps": "apps.html",
"/apps/:appId/general": "general.html",
"/apps/:appId/install-on-team": "install.html",
"/apps/:appId/oauth": "install.html"
},
"defaultRoute": "/apps"
}

View File

@ -154,6 +154,86 @@ export function planRelayConnection(
/** 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']);
/**
* Origins the BROWSER can reach n8n on — not necessarily the harness's own
* `baseUrl`.
*
* `planRelayConnection` rewrites the relay's host to `localhost` whenever it
* does not already match, so the browser can know n8n by a different spelling
* than the harness does: `http://n8n:5678` in the split-container topology, and
* `http://127.0.0.1:5678` on a laptop — cookies are host-scoped, and an IP host
* is never sent to `localhost`. The `localhost` spelling is therefore always
* included, keeping the protocol (a `secure` cookie is not sent over http).
* A cookie on an origin the browser never visits is inert; missing the one it
* does visit costs the session.
*/
export function browserN8nOrigins(baseUrl: string): string[] {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
return [];
}
const localhost = `${url.protocol}//localhost${url.port ? `:${url.port}` : ''}`;
return url.hostname === 'localhost' ? [url.origin] : [url.origin, localhost];
}
/** The client's `n8n-auth` header as a cookie for one origin. Undefined when the
* header is not a `name=value` pair. */
export function browserSessionCookie(
cookieHeader: string,
url: string,
): { name: string; value: string; url: string } | undefined {
const eq = cookieHeader.indexOf('=');
// First `=` only: the value is base64/JWT and carries its own padding.
if (eq <= 0) return undefined;
const value = cookieHeader.slice(eq + 1);
return value ? { name: cookieHeader.slice(0, eq), value, url } : undefined;
}
/**
* Give the launched browser the n8n session the harness already holds.
*
* A launched browser has never seen n8n, but the credential-setup skill opens
* n8n's OWN credential page first — for OAuth providers that is where the
* redirect URL lives. Without a session it lands on /signin and stops, and
* every expectation about the PROVIDER console then fails as though the agent
* had misbehaved (NODE-5549). `attachToRunningBrowser` needs none of this: the
* developer's browser is already signed in.
*/
async function signInBrowserToN8n(
context: BrowserContext,
client: N8nClient,
logger: EvalLogger,
): Promise<void> {
let cookieHeader: string;
try {
cookieHeader = client.cookie;
} catch {
logger.warn(' Browser not signed in to n8n: the harness holds no session cookie');
return;
}
const cookies = browserN8nOrigins(client.baseUrl)
.map((origin) => browserSessionCookie(cookieHeader, origin))
.filter((c): c is { name: string; value: string; url: string } => c !== undefined);
if (!cookies.length) {
logger.warn(' Browser not signed in to n8n: session cookie is not a name=value pair');
return;
}
try {
await context.addCookies(cookies);
} catch (error: unknown) {
// Warn-and-continue, per this helper's contract: the run still works, it
// just starts on n8n's sign-in page. Throwing here would leak the browser
// and its profile — `cleanup` is not armed until after this call.
logger.warn(
` Browser not signed in to n8n: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
logger.verbose(` Browser signed in to n8n (${cookies.map((c) => c.url).join(', ')})`);
}
/** 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]'];
@ -275,6 +355,10 @@ export async function startBrowserRuntime(
args,
});
// Before the connect page loads, so the very first navigation is already
// authenticated. Inside this try, so a throw releases the relay session.
await signInBrowserToN8n(context, client, logger);
const cleanup = async () => {
await context.close().catch(() => {});
// `finally`: rm can throw, and losing the instance-wide relay session to

View File

@ -571,6 +571,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
foreignCredentialIds,
fixture: lane.fixture,
verifyBaseUrl: lane.verifyBaseUrl,
urlField: lane.credentialUrlField,
local: lane.local,
logger,
}),

View File

@ -305,6 +305,8 @@ export async function probeCredentialValue(options: {
/** Absent in local mode — there is no stand-in to have received anything. */
fixture?: { verifyAttempts: number; verifiedOk: boolean };
verifyBaseUrl?: string;
/** Base-URL field from the fixture manifest's `urlField`. */
urlField?: string;
/** Local mode: test against the REAL provider API instead of a stand-in. */
local?: boolean;
logger: EvalLogger;
@ -316,6 +318,7 @@ export async function probeCredentialValue(options: {
foreignCredentialIds,
fixture,
verifyBaseUrl,
urlField = 'url',
local,
logger,
} = options;
@ -349,6 +352,7 @@ export async function probeCredentialValue(options: {
credentialId,
fixture,
verifyBaseUrl,
urlField,
local,
logger,
});
@ -373,10 +377,11 @@ async function probeOneCredential(options: {
credentialId: string;
fixture?: { verifyAttempts: number; verifiedOk: boolean };
verifyBaseUrl?: string;
urlField: string;
local?: boolean;
logger: EvalLogger;
}): Promise<CredentialValueProbe> {
const { client, credentialId, fixture, verifyBaseUrl, local, logger } = options;
const { client, credentialId, fixture, verifyBaseUrl, urlField, local, logger } = options;
const attemptsBefore = fixture?.verifyAttempts ?? 0;
try {
const credential = await client.getCredentialForTest(credentialId);
@ -385,9 +390,18 @@ async function probeOneCredential(options: {
// 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.
//
// `urlField` is per-provider (gemini calls it `host`). Do NOT require the
// field to be present first: a Base URL with a default is not persisted
// unless the user changed it, so real anthropic/openai credentials store
// only `apiKey` — verified against live rows. Adding the field IS the
// mechanism (n8n merges the submitted data over the stored credential), so
// requiring it would discard the check on exactly the providers it works
// for. A wrong `urlField` is caught at CI time instead, by the manifest ↔
// `test.request` lockstep in `fixture-server.test.ts`.
const result = await client.testCredential({
...credential,
data: local ? credential.data : { ...credential.data, url: verifyBaseUrl },
data: local ? credential.data : { ...credential.data, [urlField]: verifyBaseUrl },
});
if (result.status === 'OK') return { kind: 'passed', target: local ? 'real' : 'stand-in' };

View File

@ -57,6 +57,8 @@ export interface CredentialSetupLane {
/** Base URL for the provider-API stand-in, when this fixture ships one.
* Undefined => the credential-value check reports itself unverifiable. */
verifyBaseUrl?: string;
/** Manifest `urlField`. Absent in local mode, where nothing is substituted. */
credentialUrlField?: string;
close(): Promise<void>;
}
@ -162,6 +164,7 @@ export async function startCredentialSetupLane(
browser,
credentialType: fixture.manifest.credentialType,
verifyBaseUrl: fixtureServer.verifyBaseUrl,
credentialUrlField: fixture.manifest.urlField,
close: async () => {
// Browser first: it is the thing holding pages open against the fixture.
await browser.close().catch(() => {});

View File

@ -90,15 +90,36 @@ export const providerFixtureManifestSchema = z
* 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. */
// A UNION, not three optionals: the secret arrives in a header or a query
// parameter, never both and never neither, and `scheme` is meaningless
// without a header. Stating that structurally makes the nonsense
// combinations unrepresentable instead of policed after the fact.
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()
.union([
z
.object({
/** Path the credential's test request hits, e.g. `/v1/models`. */
path: z.string().startsWith('/'),
/** Header carrying the secret, e.g. `x-api-key`. */
header: z.string().min(1),
/** Prefix inside that header, e.g. `Bearer` — without it the stand-in
* compares the whole header to the bare key and refuses it. */
scheme: z.string().min(1).optional(),
})
.strict(),
z
.object({
path: z.string().startsWith('/'),
/** Query parameter carrying the secret, e.g. gemini's `key`. */
query: z.string().min(1),
})
.strict(),
])
.optional(),
/** Credential field holding the base URL, overwritten to aim the credential's
* own test at the stand-in. `url` for anthropic/openai, `host` for gemini;
* the wrong name leaves the test pointed at the real provider. */
urlField: z.string().min(1).default('url'),
})
.strict()
// Was a throw after the TLS cert had already been generated; as a refinement
@ -202,6 +223,29 @@ export async function findFixtureForCredentialType(
return fixtures.find((f) => f.manifest.credentialType === credentialType);
}
/**
* Resolve a request path to one of the declared routes, honouring `:param`
* segments (`/apps/:appId/general`).
*
* Consoles that create a resource put its id in every later URL, and the agent
* navigates by typing those URLs, not only by clicking. Without parameters the
* fixture would have to hardcode one id, and every other id would fall through
* to `defaultRoute` — serving a plausible WRONG page (an app list where the app
* settings should be) rather than an obvious dead end.
*
* Exact routes win over patterns, and a pattern matches one segment only, so a
* console's URL space cannot collapse onto a single page.
*/
export function matchRoute(routes: string[], path: string): string | undefined {
if (routes.includes(path)) return path;
const segments = path.split('/');
return routes.find((route) => {
const routeSegments = route.split('/');
if (routeSegments.length !== segments.length) return false;
return routeSegments.every((s, i) => s.startsWith(':') || s === segments[i]);
});
}
/** 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. */
@ -279,6 +323,7 @@ export async function startFixtureServer(
for (const [route, file] of Object.entries(manifest.routes)) {
pages.set(route, await readFile(join(fixture.dir, file), 'utf8'));
}
const routeKeys = [...pages.keys()];
// Non-null: the schema's refinement guarantees defaultRoute is a declared route.
const defaultPage = pages.get(manifest.defaultRoute)!;
@ -300,7 +345,8 @@ export async function startFixtureServer(
res.writeHead(204).end();
return;
}
const body = pages.get(path) ?? defaultPage;
const matched = matchRoute(routeKeys, path);
const body = (matched && pages.get(matched)) ?? defaultPage;
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(body);
});
@ -322,9 +368,14 @@ export async function startFixtureServer(
let verifiedOk = false;
let verifyAttempts = 0;
if (manifest.verify) {
const { path: verifyPath, header } = manifest.verify;
const verify = manifest.verify;
const verifyPath = verify.path;
const expected =
'scheme' in verify && verify.scheme ? `${verify.scheme} ${mintedSecret}` : mintedSecret;
const headerName = 'header' in verify ? verify.header.toLowerCase() : undefined;
const queryName = 'query' in verify ? verify.query : undefined;
verifyServer = createHttpServer((req, res) => {
const path = (req.url ?? '/').split('?')[0];
const [path, search] = (req.url ?? '/').split('?');
if (path !== verifyPath) {
events.push({ method: req.method ?? 'GET', host: 'verify', path });
res.writeHead(404).end();
@ -333,8 +384,10 @@ export async function startFixtureServer(
// 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;
const presented = headerName
? req.headers[headerName]
: new URLSearchParams(search ?? '').get(queryName!);
const ok = typeof presented === 'string' && presented === expected;
verifyAttempts += 1;
if (ok) verifiedOk = true;
events.push({ method: req.method ?? 'GET', host: 'verify', path, verifyOk: ok });