From 34bfde61d6e36a11b2cfaebe4a6ab568d865c975 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Mon, 3 Aug 2026 02:15:32 +0800 Subject: [PATCH 1/2] feat(adapters): add structured workflow profiles --- src/chrome/src/agent/adapter-workflow.js | 168 +++++++++++++++++++++ src/chrome/src/agent/adapters.js | 68 +++++++++ src/firefox/src/agent/adapter-workflow.js | 168 +++++++++++++++++++++ src/firefox/src/agent/adapters.js | 68 +++++++++ test/run.js | 176 +++++++++++++++++++++- 5 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 src/chrome/src/agent/adapter-workflow.js create mode 100644 src/firefox/src/agent/adapter-workflow.js diff --git a/src/chrome/src/agent/adapter-workflow.js b/src/chrome/src/agent/adapter-workflow.js new file mode 100644 index 000000000..55522c58d --- /dev/null +++ b/src/chrome/src/agent/adapter-workflow.js @@ -0,0 +1,168 @@ +/** + * Optional machine-readable workflow metadata for site adapters. + * + * This module is deliberately browser-free so the schema can be validated in + * Node and kept identical across Chrome and Firefox. Adapter notes remain the + * model-facing guidance; workflow profiles are an additive contract for future + * state-aware consumers. + */ + +export const ADAPTER_WORKFLOW_SCHEMA = 'webbrain-adapter-workflow/1'; + +export const ADAPTER_WORKFLOW_STATES = Object.freeze([ + 'access_gate', + 'search', + 'selection', + 'review', + 'commit', + 'payment', + 'fulfillment', + 'after_sales', +]); + +const WORKFLOW_STATE_SET = new Set(ADAPTER_WORKFLOW_STATES); +const WORKFLOW_FIELDS = new Set(['schema', 'states']); +const STATE_FIELDS = new Set([ + 'evidence', + 'readOnly', + 'requiresConfirmation', + 'terminalFor', +]); +const MAX_PROFILE_ITEMS = 16; +const MAX_EVIDENCE_ITEMS = 8; +const MAX_EVIDENCE_LENGTH = 240; + +function isPlainObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function invalid(error) { + return { ok: false, error }; +} + +function validateTokenList(value, field, pattern) { + if (!Array.isArray(value) || value.length === 0) { + return invalid(`\`${field}\` must be a non-empty array.`); + } + if (value.length > MAX_PROFILE_ITEMS) { + return invalid(`\`${field}\` must contain at most ${MAX_PROFILE_ITEMS} items.`); + } + const seen = new Set(); + for (const item of value) { + if (typeof item !== 'string' || item !== item.trim() || !pattern.test(item)) { + return invalid(`\`${field}\` entries must be stable, trimmed identifiers.`); + } + const key = item.toLowerCase(); + if (seen.has(key)) return invalid(`\`${field}\` must not contain duplicate entries.`); + seen.add(key); + } + return { ok: true }; +} + +function validateEvidence(stateName, evidence) { + if (!Array.isArray(evidence) || evidence.length === 0) { + return invalid(`Workflow state \`${stateName}\` evidence must be a non-empty array.`); + } + if (evidence.length > MAX_EVIDENCE_ITEMS) { + return invalid(`Workflow state \`${stateName}\` evidence must contain at most ${MAX_EVIDENCE_ITEMS} items.`); + } + const seen = new Set(); + for (const item of evidence) { + if (typeof item !== 'string' || item !== item.trim() || !item || item.length > MAX_EVIDENCE_LENGTH) { + return invalid(`Workflow state \`${stateName}\` evidence entries must be trimmed strings of 1-${MAX_EVIDENCE_LENGTH} characters.`); + } + const key = item.toLowerCase(); + if (seen.has(key)) { + return invalid(`Workflow state \`${stateName}\` evidence must not contain duplicate entries.`); + } + seen.add(key); + } + return { ok: true }; +} + +/** + * Validate the optional structured portion of an adapter record. + * + * Existing adapters without workflow metadata remain valid. Once any of the + * profile fields is present, regions, jobs, and workflow are all required so a + * consumer never receives a partial profile. + */ +export function validateAdapterWorkflowProfile(adapter) { + if (!isPlainObject(adapter)) return invalid('Adapter workflow profile must be an object.'); + + const hasProfile = adapter.regions !== undefined + || adapter.jobs !== undefined + || adapter.workflow !== undefined; + if (!hasProfile) return { ok: true }; + + const regions = validateTokenList(adapter.regions, 'regions', /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/); + if (!regions.ok) return regions; + const jobs = validateTokenList(adapter.jobs, 'jobs', /^[a-z][a-z0-9-]{0,63}$/); + if (!jobs.ok) return jobs; + + const workflow = adapter.workflow; + if (!isPlainObject(workflow)) return invalid('`workflow` must be an object.'); + if (workflow.schema !== ADAPTER_WORKFLOW_SCHEMA) { + return invalid(`\`workflow.schema\` must be \`${ADAPTER_WORKFLOW_SCHEMA}\`.`); + } + for (const field of Object.keys(workflow)) { + if (!WORKFLOW_FIELDS.has(field)) return invalid(`\`workflow\` has unknown field \`${field}\`.`); + } + if (!isPlainObject(workflow.states) || Object.keys(workflow.states).length === 0) { + return invalid('`workflow.states` must be a non-empty object.'); + } + + const knownJobs = new Set(adapter.jobs); + const jobsWithTerminalState = new Set(); + for (const [stateName, state] of Object.entries(workflow.states)) { + if (!WORKFLOW_STATE_SET.has(stateName)) { + return invalid(`Unknown workflow state \`${stateName}\`.`); + } + if (!isPlainObject(state)) return invalid(`Workflow state \`${stateName}\` must be an object.`); + + for (const field of Object.keys(state)) { + if (!STATE_FIELDS.has(field)) { + return invalid(`Workflow state \`${stateName}\` has unknown field \`${field}\`.`); + } + } + + const evidence = validateEvidence(stateName, state.evidence); + if (!evidence.ok) return evidence; + + for (const field of ['readOnly', 'requiresConfirmation']) { + if (state[field] !== undefined && typeof state[field] !== 'boolean') { + return invalid(`Workflow state \`${stateName}\` field \`${field}\` must be boolean.`); + } + } + if (state.readOnly === true && state.requiresConfirmation === true) { + return invalid(`Workflow state \`${stateName}\` cannot be read-only and require confirmation.`); + } + if ((stateName === 'commit' || stateName === 'payment') && state.requiresConfirmation !== true) { + return invalid(`Workflow state \`${stateName}\` must set requiresConfirmation to true.`); + } + + if (state.terminalFor !== undefined) { + if (!Array.isArray(state.terminalFor) || state.terminalFor.length === 0) { + return invalid(`Workflow state \`${stateName}\` terminalFor must be a non-empty array.`); + } + const seenTerminalJobs = new Set(); + for (const job of state.terminalFor) { + if (typeof job !== 'string' || !knownJobs.has(job)) { + return invalid(`Workflow state \`${stateName}\` terminalFor references unknown job \`${String(job)}\`.`); + } + if (seenTerminalJobs.has(job)) { + return invalid(`Workflow state \`${stateName}\` terminalFor must not contain duplicate jobs.`); + } + seenTerminalJobs.add(job); + jobsWithTerminalState.add(job); + } + } + } + + for (const job of adapter.jobs) { + if (!jobsWithTerminalState.has(job)) { + return invalid(`Workflow job \`${job}\` must have a successful terminal state with evidence.`); + } + } + return { ok: true }; +} diff --git a/src/chrome/src/agent/adapters.js b/src/chrome/src/agent/adapters.js index 2f43cd6b4..a6fd89323 100644 --- a/src/chrome/src/agent/adapters.js +++ b/src/chrome/src/agent/adapters.js @@ -1,3 +1,8 @@ +import { + ADAPTER_WORKFLOW_SCHEMA, + validateAdapterWorkflowProfile, +} from './adapter-workflow.js'; + /** * Site Adapters — per-site notes the agent receives when operating on a * known high-traffic site. The goal is NOT to encode every selector (those @@ -10,6 +15,9 @@ * - category: 'general' | 'finance' — finance gets an extra safety warning * - notes: short bulleted guidance, injected into the first user message * - fullPageCapture?.infiniteScroll(url): optional machine-readable capture policy + * - regions?: stable region identifiers for structured adapter discovery + * - jobs?: stable job identifiers covered by the optional workflow profile + * - workflow?: versioned state, evidence, confirmation, and terminal metadata * * Keep notes SHORT (4–8 bullets max). They cost tokens on every first turn. * Only encode things the model can't trivially figure out from reading the page. @@ -16374,6 +16382,46 @@ const ADAPTERS = [ { name: 'railway-12306', category: 'general', + regions: ['CN'], + jobs: ['rail-booking'], + workflow: { + schema: ADAPTER_WORKFLOW_SCHEMA, + states: { + access_gate: { + readOnly: true, + evidence: ['A QR, SMS, identity, or anti-bot challenge is visible.'], + }, + search: { + readOnly: true, + evidence: ['The departure station, arrival station, and travel date are visible.'], + }, + selection: { + readOnly: true, + evidence: ['The selected train number, stations, date, and seat class are visible.'], + }, + review: { + readOnly: true, + evidence: ['The passenger, ticket type, itinerary, seat class, and total are visible.'], + }, + commit: { + requiresConfirmation: true, + evidence: ['An order number, queue result, or pending-order status is visible.'], + }, + payment: { + requiresConfirmation: true, + evidence: ['The official payment page or payment status is visible.'], + }, + fulfillment: { + readOnly: true, + evidence: ['An order number and successful paid or ticket-issued status are visible.'], + terminalFor: ['rail-booking'], + }, + after_sales: { + requiresConfirmation: true, + evidence: ['The change or refund review and its terms are visible.'], + }, + }, + }, matches: (url) => /^https?:\/\/(?:(?:www|kyfw|passport|epay|mobile|cx|dynamic|travel)\.)?12306\.cn\//.test(url), notes: ` - Treat 12306.cn and its www, kyfw, passport, epay, mobile, cx, dynamic, and travel hosts as China Railway's official flow as of 2026-08. A step can hand off between them (for example kyfw to epay); that is still official, while any host outside 12306.cn is not. Start from the ticket form's "出发地", "到达地", and "出发日期" controls; choose the exact station when a city has multiple stations and re-read both endpoints after using the swap control. @@ -17019,3 +17067,23 @@ export function getFullPageCapturePolicy(url) { export function listAdapters() { return ADAPTERS.map(a => ({ name: a.name, category: a.category })); } + +/** + * List adapters that have migrated to the optional structured workflow schema. + * Invalid static metadata is a developer error and fails loudly here; ordinary + * adapter matching and notes injection remain unaffected. + */ +export function listAdapterWorkflowProfiles() { + return ADAPTERS.filter(a => a.workflow).map((adapter) => { + const validation = validateAdapterWorkflowProfile(adapter); + if (!validation.ok) { + throw new Error(`Invalid workflow profile for adapter \`${adapter.name}\`: ${validation.error}`); + } + return { + name: adapter.name, + regions: [...adapter.regions], + jobs: [...adapter.jobs], + workflow: adapter.workflow, + }; + }); +} diff --git a/src/firefox/src/agent/adapter-workflow.js b/src/firefox/src/agent/adapter-workflow.js new file mode 100644 index 000000000..55522c58d --- /dev/null +++ b/src/firefox/src/agent/adapter-workflow.js @@ -0,0 +1,168 @@ +/** + * Optional machine-readable workflow metadata for site adapters. + * + * This module is deliberately browser-free so the schema can be validated in + * Node and kept identical across Chrome and Firefox. Adapter notes remain the + * model-facing guidance; workflow profiles are an additive contract for future + * state-aware consumers. + */ + +export const ADAPTER_WORKFLOW_SCHEMA = 'webbrain-adapter-workflow/1'; + +export const ADAPTER_WORKFLOW_STATES = Object.freeze([ + 'access_gate', + 'search', + 'selection', + 'review', + 'commit', + 'payment', + 'fulfillment', + 'after_sales', +]); + +const WORKFLOW_STATE_SET = new Set(ADAPTER_WORKFLOW_STATES); +const WORKFLOW_FIELDS = new Set(['schema', 'states']); +const STATE_FIELDS = new Set([ + 'evidence', + 'readOnly', + 'requiresConfirmation', + 'terminalFor', +]); +const MAX_PROFILE_ITEMS = 16; +const MAX_EVIDENCE_ITEMS = 8; +const MAX_EVIDENCE_LENGTH = 240; + +function isPlainObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function invalid(error) { + return { ok: false, error }; +} + +function validateTokenList(value, field, pattern) { + if (!Array.isArray(value) || value.length === 0) { + return invalid(`\`${field}\` must be a non-empty array.`); + } + if (value.length > MAX_PROFILE_ITEMS) { + return invalid(`\`${field}\` must contain at most ${MAX_PROFILE_ITEMS} items.`); + } + const seen = new Set(); + for (const item of value) { + if (typeof item !== 'string' || item !== item.trim() || !pattern.test(item)) { + return invalid(`\`${field}\` entries must be stable, trimmed identifiers.`); + } + const key = item.toLowerCase(); + if (seen.has(key)) return invalid(`\`${field}\` must not contain duplicate entries.`); + seen.add(key); + } + return { ok: true }; +} + +function validateEvidence(stateName, evidence) { + if (!Array.isArray(evidence) || evidence.length === 0) { + return invalid(`Workflow state \`${stateName}\` evidence must be a non-empty array.`); + } + if (evidence.length > MAX_EVIDENCE_ITEMS) { + return invalid(`Workflow state \`${stateName}\` evidence must contain at most ${MAX_EVIDENCE_ITEMS} items.`); + } + const seen = new Set(); + for (const item of evidence) { + if (typeof item !== 'string' || item !== item.trim() || !item || item.length > MAX_EVIDENCE_LENGTH) { + return invalid(`Workflow state \`${stateName}\` evidence entries must be trimmed strings of 1-${MAX_EVIDENCE_LENGTH} characters.`); + } + const key = item.toLowerCase(); + if (seen.has(key)) { + return invalid(`Workflow state \`${stateName}\` evidence must not contain duplicate entries.`); + } + seen.add(key); + } + return { ok: true }; +} + +/** + * Validate the optional structured portion of an adapter record. + * + * Existing adapters without workflow metadata remain valid. Once any of the + * profile fields is present, regions, jobs, and workflow are all required so a + * consumer never receives a partial profile. + */ +export function validateAdapterWorkflowProfile(adapter) { + if (!isPlainObject(adapter)) return invalid('Adapter workflow profile must be an object.'); + + const hasProfile = adapter.regions !== undefined + || adapter.jobs !== undefined + || adapter.workflow !== undefined; + if (!hasProfile) return { ok: true }; + + const regions = validateTokenList(adapter.regions, 'regions', /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/); + if (!regions.ok) return regions; + const jobs = validateTokenList(adapter.jobs, 'jobs', /^[a-z][a-z0-9-]{0,63}$/); + if (!jobs.ok) return jobs; + + const workflow = adapter.workflow; + if (!isPlainObject(workflow)) return invalid('`workflow` must be an object.'); + if (workflow.schema !== ADAPTER_WORKFLOW_SCHEMA) { + return invalid(`\`workflow.schema\` must be \`${ADAPTER_WORKFLOW_SCHEMA}\`.`); + } + for (const field of Object.keys(workflow)) { + if (!WORKFLOW_FIELDS.has(field)) return invalid(`\`workflow\` has unknown field \`${field}\`.`); + } + if (!isPlainObject(workflow.states) || Object.keys(workflow.states).length === 0) { + return invalid('`workflow.states` must be a non-empty object.'); + } + + const knownJobs = new Set(adapter.jobs); + const jobsWithTerminalState = new Set(); + for (const [stateName, state] of Object.entries(workflow.states)) { + if (!WORKFLOW_STATE_SET.has(stateName)) { + return invalid(`Unknown workflow state \`${stateName}\`.`); + } + if (!isPlainObject(state)) return invalid(`Workflow state \`${stateName}\` must be an object.`); + + for (const field of Object.keys(state)) { + if (!STATE_FIELDS.has(field)) { + return invalid(`Workflow state \`${stateName}\` has unknown field \`${field}\`.`); + } + } + + const evidence = validateEvidence(stateName, state.evidence); + if (!evidence.ok) return evidence; + + for (const field of ['readOnly', 'requiresConfirmation']) { + if (state[field] !== undefined && typeof state[field] !== 'boolean') { + return invalid(`Workflow state \`${stateName}\` field \`${field}\` must be boolean.`); + } + } + if (state.readOnly === true && state.requiresConfirmation === true) { + return invalid(`Workflow state \`${stateName}\` cannot be read-only and require confirmation.`); + } + if ((stateName === 'commit' || stateName === 'payment') && state.requiresConfirmation !== true) { + return invalid(`Workflow state \`${stateName}\` must set requiresConfirmation to true.`); + } + + if (state.terminalFor !== undefined) { + if (!Array.isArray(state.terminalFor) || state.terminalFor.length === 0) { + return invalid(`Workflow state \`${stateName}\` terminalFor must be a non-empty array.`); + } + const seenTerminalJobs = new Set(); + for (const job of state.terminalFor) { + if (typeof job !== 'string' || !knownJobs.has(job)) { + return invalid(`Workflow state \`${stateName}\` terminalFor references unknown job \`${String(job)}\`.`); + } + if (seenTerminalJobs.has(job)) { + return invalid(`Workflow state \`${stateName}\` terminalFor must not contain duplicate jobs.`); + } + seenTerminalJobs.add(job); + jobsWithTerminalState.add(job); + } + } + } + + for (const job of adapter.jobs) { + if (!jobsWithTerminalState.has(job)) { + return invalid(`Workflow job \`${job}\` must have a successful terminal state with evidence.`); + } + } + return { ok: true }; +} diff --git a/src/firefox/src/agent/adapters.js b/src/firefox/src/agent/adapters.js index 2e2a4f19f..171d56ab7 100644 --- a/src/firefox/src/agent/adapters.js +++ b/src/firefox/src/agent/adapters.js @@ -1,3 +1,8 @@ +import { + ADAPTER_WORKFLOW_SCHEMA, + validateAdapterWorkflowProfile, +} from './adapter-workflow.js'; + /** * Site Adapters — per-site notes the agent receives when operating on a * known high-traffic site. The goal is NOT to encode every selector (those @@ -10,6 +15,9 @@ * - category: 'general' | 'finance' — finance gets an extra safety warning * - notes: short bulleted guidance, injected into the first user message * - fullPageCapture?.infiniteScroll(url): optional machine-readable capture policy + * - regions?: stable region identifiers for structured adapter discovery + * - jobs?: stable job identifiers covered by the optional workflow profile + * - workflow?: versioned state, evidence, confirmation, and terminal metadata * * Keep notes SHORT (4–8 bullets max). They cost tokens on every first turn. * Only encode things the model can't trivially figure out from reading the page. @@ -16372,6 +16380,46 @@ const ADAPTERS = [ { name: 'railway-12306', category: 'general', + regions: ['CN'], + jobs: ['rail-booking'], + workflow: { + schema: ADAPTER_WORKFLOW_SCHEMA, + states: { + access_gate: { + readOnly: true, + evidence: ['A QR, SMS, identity, or anti-bot challenge is visible.'], + }, + search: { + readOnly: true, + evidence: ['The departure station, arrival station, and travel date are visible.'], + }, + selection: { + readOnly: true, + evidence: ['The selected train number, stations, date, and seat class are visible.'], + }, + review: { + readOnly: true, + evidence: ['The passenger, ticket type, itinerary, seat class, and total are visible.'], + }, + commit: { + requiresConfirmation: true, + evidence: ['An order number, queue result, or pending-order status is visible.'], + }, + payment: { + requiresConfirmation: true, + evidence: ['The official payment page or payment status is visible.'], + }, + fulfillment: { + readOnly: true, + evidence: ['An order number and successful paid or ticket-issued status are visible.'], + terminalFor: ['rail-booking'], + }, + after_sales: { + requiresConfirmation: true, + evidence: ['The change or refund review and its terms are visible.'], + }, + }, + }, matches: (url) => /^https?:\/\/(?:(?:www|kyfw|passport|epay|mobile|cx|dynamic|travel)\.)?12306\.cn\//.test(url), notes: ` - Treat 12306.cn and its www, kyfw, passport, epay, mobile, cx, dynamic, and travel hosts as China Railway's official flow as of 2026-08. A step can hand off between them (for example kyfw to epay); that is still official, while any host outside 12306.cn is not. Start from the ticket form's "出发地", "到达地", and "出发日期" controls; choose the exact station when a city has multiple stations and re-read both endpoints after using the swap control. @@ -17017,3 +17065,23 @@ export function getFullPageCapturePolicy(url) { export function listAdapters() { return ADAPTERS.map(a => ({ name: a.name, category: a.category })); } + +/** + * List adapters that have migrated to the optional structured workflow schema. + * Invalid static metadata is a developer error and fails loudly here; ordinary + * adapter matching and notes injection remain unaffected. + */ +export function listAdapterWorkflowProfiles() { + return ADAPTERS.filter(a => a.workflow).map((adapter) => { + const validation = validateAdapterWorkflowProfile(adapter); + if (!validation.ok) { + throw new Error(`Invalid workflow profile for adapter \`${adapter.name}\`: ${validation.error}`); + } + return { + name: adapter.name, + regions: [...adapter.regions], + jobs: [...adapter.jobs], + workflow: adapter.workflow, + }; + }); +} diff --git a/test/run.js b/test/run.js index f4f0159d7..8d1adb7b0 100644 --- a/test/run.js +++ b/test/run.js @@ -224,15 +224,35 @@ function binaryResponse(status, body = 'media-bytes', contentType = 'video/mp4', // ──────────────────────────────────────────────────────────────────────── // adapters.js is pure ESM with no chrome.* deps — import directly. -const { getActiveAdapter, getFullPageCapturePolicy, listAdapters } = await import( +const { + getActiveAdapter, + getFullPageCapturePolicy, + listAdapters, + listAdapterWorkflowProfiles, +} = await import( 'file://' + path.join(ROOT, 'src/chrome/src/agent/adapters.js').replace(/\\/g, '/') ); const { getActiveAdapter: getActiveAdapterFx, getFullPageCapturePolicy: getFullPageCapturePolicyFx, + listAdapterWorkflowProfiles: listAdapterWorkflowProfilesFx, } = await import( 'file://' + path.join(ROOT, 'src/firefox/src/agent/adapters.js').replace(/\\/g, '/') ); +const { + ADAPTER_WORKFLOW_SCHEMA, + ADAPTER_WORKFLOW_STATES, + validateAdapterWorkflowProfile, +} = await import( + 'file://' + path.join(ROOT, 'src/chrome/src/agent/adapter-workflow.js').replace(/\\/g, '/') +); +const { + ADAPTER_WORKFLOW_SCHEMA: ADAPTER_WORKFLOW_SCHEMA_FX, + ADAPTER_WORKFLOW_STATES: ADAPTER_WORKFLOW_STATES_FX, + validateAdapterWorkflowProfile: validateAdapterWorkflowProfileFx, +} = await import( + 'file://' + path.join(ROOT, 'src/firefox/src/agent/adapter-workflow.js').replace(/\\/g, '/') +); // trace-export.js is pure ESM — the /export --traces serializer, tested here. const { tracesToMarkdown } = await import( @@ -3665,6 +3685,160 @@ test('every adapter has the required fields', () => { } }); +test('adapter workflow profile accepts read-only and consequential jobs', () => { + const readOnly = { + regions: ['global'], + jobs: ['site-search'], + workflow: { + schema: ADAPTER_WORKFLOW_SCHEMA, + states: { + search: { + readOnly: true, + evidence: ['The query and result set are visible.'], + terminalFor: ['site-search'], + }, + }, + }, + }; + const consequential = { + regions: ['US'], + jobs: ['purchase'], + workflow: { + schema: ADAPTER_WORKFLOW_SCHEMA, + states: { + review: { readOnly: true, evidence: ['The final item and total are visible.'] }, + commit: { + requiresConfirmation: true, + evidence: ['The order submission result is visible.'], + }, + payment: { + requiresConfirmation: true, + evidence: ['The payment status is visible.'], + }, + fulfillment: { + evidence: ['An order number and confirmed status are visible.'], + terminalFor: ['purchase'], + }, + }, + }, + }; + + assert.deepEqual(validateAdapterWorkflowProfile(readOnly), { ok: true }); + assert.deepEqual(validateAdapterWorkflowProfile(consequential), { ok: true }); + assert.deepEqual(validateAdapterWorkflowProfileFx(readOnly), { ok: true }); + assert.deepEqual(validateAdapterWorkflowProfileFx(consequential), { ok: true }); +}); + +test('adapter workflow profile rejects unsafe or unverifiable state models', () => { + const profile = (states, jobs = ['purchase']) => ({ + regions: ['US'], + jobs, + workflow: { schema: ADAPTER_WORKFLOW_SCHEMA, states }, + }); + const cases = [ + { + value: { ...profile({ search: { evidence: ['Results visible.'], terminalFor: ['purchase'] } }), regions: [] }, + error: /regions.*non-empty/i, + }, + { + value: { + ...profile({ search: { evidence: ['Results visible.'], terminalFor: ['purchase'] } }), + workflow: { + schema: 'webbrain-adapter-workflow/2', + states: { search: { evidence: ['Results visible.'], terminalFor: ['purchase'] } }, + }, + }, + error: /workflow\.schema.*webbrain-adapter-workflow\/1/i, + }, + { + value: { + ...profile({ search: { evidence: ['Results visible.'], terminalFor: ['purchase'] } }), + workflow: { + schema: ADAPTER_WORKFLOW_SCHEMA, + states: { search: { evidence: ['Results visible.'], terminalFor: ['purchase'] } }, + transitions: [], + }, + }, + error: /workflow.*unknown field.*transitions/i, + }, + { + value: profile( + { search: { evidence: ['Results visible.'], terminalFor: ['purchase'] } }, + ['purchase', 'purchase'], + ), + error: /jobs.*duplicate/i, + }, + { + value: profile({ search: { evidence: [], terminalFor: ['purchase'] } }), + error: /search.*evidence.*non-empty/i, + }, + { + value: profile({ search: { evidence: ['Results visible.', 'results visible.'], terminalFor: ['purchase'] } }), + error: /search.*evidence.*duplicate/i, + }, + { + value: profile({ search: { + readOnly: true, + requiresConfirmation: true, + evidence: ['Results visible.'], + terminalFor: ['purchase'], + } }), + error: /search.*cannot be read-only and require confirmation/i, + }, + { + value: profile({ commit: { evidence: ['Submission visible.'], terminalFor: ['purchase'] } }), + error: /commit.*requiresConfirmation.*true/i, + }, + { + value: profile({ search: { evidence: ['Results visible.'], terminalFor: ['unknown-job'] } }), + error: /terminalFor.*unknown-job/i, + }, + { + value: profile({ selection: { readOnly: true, evidence: ['Selection visible.'] } }), + error: /purchase.*successful terminal state/i, + }, + { + value: profile({ checkout: { evidence: ['Checkout visible.'], terminalFor: ['purchase'] } }), + error: /unknown workflow state.*checkout/i, + }, + { + value: profile({ search: { evidence: ['Results visible.'], terminalFor: ['purchase'], selector: '#results' } }), + error: /search.*unknown field.*selector/i, + }, + ]; + + for (const { value, error } of cases) { + const chromeResult = validateAdapterWorkflowProfile(value); + const firefoxResult = validateAdapterWorkflowProfileFx(value); + assert.equal(chromeResult.ok, false); + assert.match(chromeResult.error, error); + assert.deepEqual(firefoxResult, chromeResult); + } +}); + +test('12306 exposes a validated regional workflow profile with browser parity', () => { + const chromeAdapter = getActiveAdapter('https://kyfw.12306.cn/otn/confirmPassenger/initDc'); + const firefoxAdapter = getActiveAdapterFx('https://epay.12306.cn/pay/webBusiness'); + + assert.equal(chromeAdapter?.name, 'railway-12306'); + assert.equal(firefoxAdapter?.name, 'railway-12306'); + assert.deepEqual(validateAdapterWorkflowProfile(chromeAdapter), { ok: true }); + assert.deepEqual(validateAdapterWorkflowProfileFx(firefoxAdapter), { ok: true }); + assert.deepEqual(chromeAdapter?.regions, ['CN']); + assert.deepEqual(chromeAdapter?.jobs, ['rail-booking']); + assert.deepEqual(firefoxAdapter?.regions, chromeAdapter?.regions); + assert.deepEqual(firefoxAdapter?.jobs, chromeAdapter?.jobs); + assert.deepEqual(firefoxAdapter?.workflow, chromeAdapter?.workflow); + assert.deepEqual(ADAPTER_WORKFLOW_STATES_FX, ADAPTER_WORKFLOW_STATES); + assert.equal(ADAPTER_WORKFLOW_SCHEMA_FX, ADAPTER_WORKFLOW_SCHEMA); + + const chromeProfiles = listAdapterWorkflowProfiles(); + const firefoxProfiles = listAdapterWorkflowProfilesFx(); + assert.deepEqual(chromeProfiles.map(profile => profile.name), ['railway-12306']); + assert.deepEqual(firefoxProfiles, chromeProfiles); + assert.deepEqual(validateAdapterWorkflowProfile(chromeProfiles[0]), { ok: true }); +}); + test('finance adapters take precedence in order — stripe before generic', () => { // Stripe URL should match stripe, not the generic finance pattern. const a = getActiveAdapter('https://dashboard.stripe.com/'); From dfdf15cf8cbe0ae9e5ef55f4eb4c8601dcc1fb2d Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Mon, 3 Aug 2026 09:25:55 +0300 Subject: [PATCH 2/2] fix(adapters): harden workflow profile enumeration --- src/chrome/src/agent/adapters.js | 29 +++++++++++--- src/firefox/src/agent/adapters.js | 29 +++++++++++--- test/run.js | 66 +++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 10 deletions(-) diff --git a/src/chrome/src/agent/adapters.js b/src/chrome/src/agent/adapters.js index a6fd89323..0d3237778 100644 --- a/src/chrome/src/agent/adapters.js +++ b/src/chrome/src/agent/adapters.js @@ -17074,16 +17074,35 @@ export function listAdapters() { * adapter matching and notes injection remain unaffected. */ export function listAdapterWorkflowProfiles() { - return ADAPTERS.filter(a => a.workflow).map((adapter) => { + const profiles = []; + for (const adapter of ADAPTERS) { + const hasProfile = adapter.regions !== undefined + || adapter.jobs !== undefined + || adapter.workflow !== undefined; + if (!hasProfile) continue; + const validation = validateAdapterWorkflowProfile(adapter); if (!validation.ok) { throw new Error(`Invalid workflow profile for adapter \`${adapter.name}\`: ${validation.error}`); } - return { + profiles.push({ name: adapter.name, regions: [...adapter.regions], jobs: [...adapter.jobs], - workflow: adapter.workflow, - }; - }); + workflow: { + schema: adapter.workflow.schema, + states: Object.fromEntries( + Object.entries(adapter.workflow.states).map(([stateName, state]) => [ + stateName, + { + ...state, + evidence: [...state.evidence], + ...(state.terminalFor === undefined ? {} : { terminalFor: [...state.terminalFor] }), + }, + ]), + ), + }, + }); + } + return profiles; } diff --git a/src/firefox/src/agent/adapters.js b/src/firefox/src/agent/adapters.js index 171d56ab7..c4d1d7f6b 100644 --- a/src/firefox/src/agent/adapters.js +++ b/src/firefox/src/agent/adapters.js @@ -17072,16 +17072,35 @@ export function listAdapters() { * adapter matching and notes injection remain unaffected. */ export function listAdapterWorkflowProfiles() { - return ADAPTERS.filter(a => a.workflow).map((adapter) => { + const profiles = []; + for (const adapter of ADAPTERS) { + const hasProfile = adapter.regions !== undefined + || adapter.jobs !== undefined + || adapter.workflow !== undefined; + if (!hasProfile) continue; + const validation = validateAdapterWorkflowProfile(adapter); if (!validation.ok) { throw new Error(`Invalid workflow profile for adapter \`${adapter.name}\`: ${validation.error}`); } - return { + profiles.push({ name: adapter.name, regions: [...adapter.regions], jobs: [...adapter.jobs], - workflow: adapter.workflow, - }; - }); + workflow: { + schema: adapter.workflow.schema, + states: Object.fromEntries( + Object.entries(adapter.workflow.states).map(([stateName, state]) => [ + stateName, + { + ...state, + evidence: [...state.evidence], + ...(state.terminalFor === undefined ? {} : { terminalFor: [...state.terminalFor] }), + }, + ]), + ), + }, + }); + } + return profiles; } diff --git a/test/run.js b/test/run.js index 8d1adb7b0..de35ea7a3 100644 --- a/test/run.js +++ b/test/run.js @@ -3839,6 +3839,72 @@ test('12306 exposes a validated regional workflow profile with browser parity', assert.deepEqual(validateAdapterWorkflowProfile(chromeProfiles[0]), { ok: true }); }); +test('workflow profile enumeration rejects partial entries and returns detached snapshots', () => { + const builds = [ + { + label: 'chrome', + getAdapter: getActiveAdapter, + listProfiles: listAdapterWorkflowProfiles, + validateProfile: validateAdapterWorkflowProfile, + }, + { + label: 'firefox', + getAdapter: getActiveAdapterFx, + listProfiles: listAdapterWorkflowProfilesFx, + validateProfile: validateAdapterWorkflowProfileFx, + }, + ]; + + for (const { label, getAdapter, listProfiles, validateProfile } of builds) { + const adapter = getAdapter('https://www.12306.cn/'); + assert.ok(adapter?.workflow, `${label}: expected the 12306 workflow profile`); + + const originalWorkflow = adapter.workflow; + try { + delete adapter.workflow; + assert.throws( + () => listProfiles(), + /`workflow` must be an object/, + `${label}: partial profile metadata must fail enumeration`, + ); + } finally { + adapter.workflow = originalWorkflow; + } + + const sourceSnapshot = structuredClone(adapter.workflow); + try { + const profile = listProfiles()[0]; + profile.regions.push('tampered'); + profile.jobs.push('tampered'); + profile.workflow.schema = 'tampered'; + profile.workflow.states.commit.requiresConfirmation = false; + profile.workflow.states.fulfillment.evidence.push('Tampered evidence.'); + profile.workflow.states.fulfillment.terminalFor.push('tampered'); + + const fresh = listProfiles()[0]; + assert.deepEqual(fresh.regions, ['CN'], `${label}: regions leaked a caller mutation`); + assert.deepEqual(fresh.jobs, ['rail-booking'], `${label}: jobs leaked a caller mutation`); + assert.equal(fresh.workflow.schema, ADAPTER_WORKFLOW_SCHEMA, `${label}: schema leaked a caller mutation`); + assert.equal(fresh.workflow.states.commit.requiresConfirmation, true, `${label}: state fields leaked a caller mutation`); + assert.deepEqual( + fresh.workflow.states.fulfillment.evidence, + ['An order number and successful paid or ticket-issued status are visible.'], + `${label}: evidence leaked a caller mutation`, + ); + assert.deepEqual( + fresh.workflow.states.fulfillment.terminalFor, + ['rail-booking'], + `${label}: terminal jobs leaked a caller mutation`, + ); + assert.deepEqual(validateProfile(fresh), { ok: true }); + } finally { + adapter.workflow = sourceSnapshot; + } + } + + assert.deepEqual(listAdapterWorkflowProfilesFx(), listAdapterWorkflowProfiles()); +}); + test('finance adapters take precedence in order — stripe before generic', () => { // Stripe URL should match stripe, not the generic finance pattern. const a = getActiveAdapter('https://dashboard.stripe.com/');