diff --git a/package.json b/package.json
index bb7a88f..d6eb176 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,8 @@
"verify:aesrsa": "node scripts/verify-aesrsa-transaction.mjs",
"verify:aesserver": "node scripts/verify-aesserver-transaction.mjs",
"verify:des": "node scripts/verify-des-transaction.mjs",
+ "verify:capture": "pnpm build:enterprise && node scripts/verify-capture-regressions.mjs && node scripts/verify-login-gateway.mjs",
+ "verify:agent-gateway": "pnpm build:enterprise && node scripts/verify-agent-gateway.mjs",
"verify:agent-contract:aes": "node scripts/verify-aes-agent-contract.mjs",
"verify:agent-contract:aesrsa": "node scripts/verify-aesrsa-transaction.mjs",
"verify:agent-contract:holdout": "AGENT_CONTRACT_HOLDOUT_ONLY=1 EXTENSION_PATH=.output/chrome-mv3-enterprise node scripts/verify-ui.mjs",
diff --git a/scripts/verify-agent-gateway.mjs b/scripts/verify-agent-gateway.mjs
new file mode 100644
index 0000000..33cb1e1
--- /dev/null
+++ b/scripts/verify-agent-gateway.mjs
@@ -0,0 +1,113 @@
+import assert from 'node:assert/strict'
+import { spawn } from 'node:child_process'
+import { resolve } from 'node:path'
+import { createDecipheriv } from 'node:crypto'
+import { extensionRequest, launchBrowserAgentContractHarness } from './browser-agent-contract-harness.mjs'
+
+function body(packet) { return JSON.parse(packet.raw.slice(packet.raw.indexOf('\r\n\r\n') + 4)) }
+function decrypt(envelope) {
+ const decipher = createDecipheriv('aes-128-cbc', Buffer.from(envelope.key, 'hex'), Buffer.from(envelope.iv, 'hex'))
+ return JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.message, 'base64')), decipher.final()]).toString())
+}
+
+const targetURL = process.env.LOGIN_TARGET || 'http://localhost:8080/crypto/sqli/aes-ecb/encrypt/login'
+const engine = spawn('go', ['test', './common/yakgrpc', '-run', '^TestBrowserAgentLiveGateway$', '-count=1', '-v'], {
+ cwd: process.env.YAKLANG_ROOT || resolve(import.meta.dirname, '../../../go/yaklang'),
+ env: { ...process.env, YAK_BROWSER_AGENT_E2E: '1' }, stdio: ['ignore', 'pipe', 'pipe'],
+})
+let output = ''
+let harness, connection
+const exited = new Promise(resolve => engine.once('exit', code => resolve(code)))
+const ready = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`Go Agent bridge startup timed out: ${output.slice(-4000)}`)), 120_000)
+ engine.stdout.on('data', chunk => {
+ output += chunk
+ const match = output.match(/YAK_AGENT_E2E=(\{[^\n]+\})/)
+ if (match) { clearTimeout(timer); resolve(JSON.parse(match[1])) }
+ })
+ engine.stderr.on('data', chunk => { output += chunk })
+ engine.once('error', error => { clearTimeout(timer); reject(error) })
+ engine.once('exit', code => { clearTimeout(timer); reject(new Error(`Go bridge exited ${code}: ${output}`)) })
+})
+try {
+ connection = await ready
+ const tool = async (name, params = {}) => {
+ const response = await fetch(connection.endpoint, { method: 'POST', headers: { 'X-Test-Token': connection.token }, body: JSON.stringify({ tool: name, params }) })
+ if (!response.ok) throw new Error(`${name}: ${await response.text()}`)
+ return response.json()
+ }
+ harness = await launchBrowserAgentContractHarness({ profilePrefix: 'yakit-agent-gateway-', targetURL })
+ const { controlPage, targetPage, tabId } = harness
+ let completed = 0, blocked = 0
+ targetPage.on('response', response => { if (response.url() === targetURL && response.request().method() === 'POST') completed++ })
+ targetPage.on('requestfailed', request => { if (request.url() === targetURL && request.failure()?.errorText.includes('BLOCKED_BY_CLIENT')) blocked++ })
+ const state = await controlPage.evaluate(async () => (await chrome.runtime.sendMessage({ action: 'state.get' })).data)
+ await extensionRequest(controlPage, 'bridge.config.save', {
+ transport: 'websocket', nativeHost: 'com.yaklang.browser_agent', endpoint: connection.bridge,
+ autoConnect: false, installationId: state.bridge.installationId,
+ })
+ await controlPage.evaluate(async () => {
+ const result = await chrome.runtime.sendMessage({ action: 'bridge.pair' })
+ if (!result.ok) throw new Error(JSON.stringify(result.error))
+ })
+ for (let attempt = 0; ; attempt++) {
+ const status = await controlPage.evaluate(async () => (await chrome.runtime.sendMessage({ action: 'bridge.status' })).data)
+ if (status.state === 'connected') break
+ if (attempt === 100) throw new Error(`Bridge did not connect: ${JSON.stringify(status)}`)
+ await controlPage.waitForTimeout(100)
+ }
+ // Close the extension UI. All workflow calls below go through Go Agent tools.
+ // A previously released human debugging session must not lock out the Agent.
+ await extensionRequest(controlPage, 'deep.capture.start', { tabId, frameId: 0, matcher: { kind: 'request', urlPattern: '/not-triggered' } })
+ await extensionRequest(controlPage, 'deep.capture.detach', { tabId, frameId: 0 })
+ await controlPage.close()
+ const call = (method, params = {}) => tool('browser.capability.call', { method, params: { tabId, frameId: 0, ...params } })
+ const catalog = await tool('browser.capability.catalog', { domain: 'debugger' })
+ assert(catalog.capabilities.some(item => item.method === 'browser.deep_capture.start'))
+ const context = await call('browser.context', { includeDom: true })
+ const nodes = context.document.interactive
+ const username = nodes.find(node => node.name === 'username' || node.id === 'username')
+ const password = nodes.find(node => node.name === 'password' || node.id === 'password')
+ const submit = nodes.find(node => node.tag === 'button' && node.type === 'submit')
+ assert(username && password && submit, JSON.stringify(nodes))
+ for (const [node, value] of [[username, 'agent-original'], [password, 'agent-original-wrong']]) {
+ await call('browser.node.action', { captureId: context.captureId, nodeId: node.nodeId, action: 'setValue', value })
+ }
+ const inspection = await tool('browser.crypto.inspect', { tabId, frameId: 0, captureId: context.captureId, nodeId: submit.nodeId })
+ assert.equal(inspection.gatewayPreparation.state, 'capture-required')
+ const plaintext = { username: 'agent-new-user', password: 'agent-new-wrong' }
+ const url = new URL(targetURL)
+ const request = `POST ${url.pathname} HTTP/1.1\r\nHost: ${url.host}\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(plaintext)}`
+ const prepareInput = { ...inspection.target, candidate_id: inspection.gatewayPreparation.candidateId, request, is_https: false }
+ await assert.rejects(tool('browser.transform.prepare', { ...prepareInput, captureId: 'stale-context', nodeId: submit.nodeId }), /快照已经失效/)
+ assert.equal((await call('browser.deep_capture.status')).state, 'detached', 'failed preparation must release its debugger')
+ assert.equal(completed, 1)
+ const prepared = await tool('browser.transform.prepare', prepareInput)
+ assert(prepared.valid, JSON.stringify(prepared))
+ assert.deepEqual(prepared.validationDraft.directions, { request: true, response: true })
+ assert.equal(completed, 1, 'automatic preparation must not submit another real browser login')
+ assert.equal(blocked, 1)
+ const preparedAgain = await tool('browser.transform.prepare', prepareInput)
+ assert(preparedAgain.valid)
+ assert.equal(blocked, 1, 'repeated preparation must reuse existing captured functions')
+ const tested = await tool('browser.http.test', { validation_id: preparedAgain.validationDraft.id, request, is_https: false })
+ assert(tested.responseTransformEnabled, JSON.stringify(tested))
+ assert(tested.requestTransformed, JSON.stringify(tested))
+ assert(tested.responseTransformed)
+ assert.deepEqual(decrypt(body(tested.wireRequest)), plaintext)
+ assert.deepEqual(body(tested.plaintextResponse), decrypt(body(tested.wireResponse)))
+ const draft = await call('browser.profile.validation.latest')
+ const saved = await tool('browser.capability.call', { method: 'browser.transform.profile.save', params: draft.profile })
+ assert(saved.request.enabled && saved.response.enabled)
+ await tool('browser.capability.call', { method: 'browser.transform.profile.delete', params: { id: saved.id } })
+ assert.equal(completed, 1)
+ assert.equal(await targetPage.locator('#username').inputValue(), 'agent-original')
+ assert.equal(await targetPage.locator('#password').inputValue(), 'agent-original-wrong')
+ console.log('Agent gateway passed: signed Go bridge, advanced capability access, no plugin UI, failed-capture cleanup, automatic business capture, bidirectional validation, independently checked request/response crypto, default response decryption and Profile save/delete.')
+} finally {
+ await harness?.close()
+ if (connection) await fetch(`${connection.endpoint}/finish`, { method: 'POST', headers: { 'X-Test-Token': connection.token } }).catch(() => undefined)
+ else engine.kill('SIGTERM')
+ const code = await exited
+ if (code !== 0 && connection) throw new Error(`Go integration exited ${code}: ${output.slice(-4000)}`)
+}
diff --git a/scripts/verify-capture-regressions.mjs b/scripts/verify-capture-regressions.mjs
new file mode 100644
index 0000000..207ccb0
--- /dev/null
+++ b/scripts/verify-capture-regressions.mjs
@@ -0,0 +1,142 @@
+import assert from 'node:assert/strict'
+import { createServer } from 'node:http'
+import { readFile } from 'node:fs/promises'
+import { extensionRequest, launchBrowserAgentContractHarness, waitFor } from './browser-agent-contract-harness.mjs'
+
+const registryKey = (await readFile(new URL('../src/features/page-callable/constants.ts', import.meta.url), 'utf8')).match(/= '([^']+)'/)[1]
+const received = []
+const server = createServer(async (request, response) => {
+ let body = ''
+ for await (const chunk of request) body += chunk
+ received.push({ url: request.url, method: request.method, body })
+ if (request.url === '/key' || request.url === '/bad-key') {
+ response.setHeader('Content-Type', 'application/json')
+ response.end(JSON.stringify(request.url === '/key' ? { key: 'fresh-server-key' } : {}))
+ } else if (request.method === 'POST') {
+ response.setHeader('Content-Type', 'application/json')
+ response.end('{}')
+ } else {
+ response.setHeader('Content-Type', 'text/html')
+ response.end('
')
+ }
+})
+await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
+const targetURL = `http://127.0.0.1:${server.address().port}/`
+let harness
+try {
+ harness = await launchBrowserAgentContractHarness({ profilePrefix: 'yakit-capture-regressions-', targetURL })
+ const { controlPage, targetPage, tabId } = harness
+ const target = { tabId, frameId: 0 }
+ const request = (action, payload = {}) => extensionRequest(controlPage, action, { ...target, ...payload })
+ await request('recording.start', { captureValues: true, maxEntries: 200, maxValueBytes: 8192 })
+
+ await targetPage.evaluate(async () => {
+ await fetch(new Request(location.origin + '/request-object', { method: 'POST', body: 'encrypted-request-body' }))
+ })
+ const recorded = await waitFor(controlPage, 'recording.get', target, snapshot => snapshot.events.some(event =>
+ event.url?.endsWith('/request-object') && event.inputs.some(value => value.path === '$body' && value.preview === 'encrypted-request-body')))
+ assert(recorded.events.some(event => event.url?.endsWith('/request-object')))
+
+ async function register(mode, transaction = false) {
+ return targetPage.evaluate(({ key, mode, transaction }) => {
+ const id = crypto.randomUUID()
+ const endpoint = location.origin + '/' + mode
+ const savedState = { password: 'old' }
+ const prerequisites = mode.startsWith('prerequisite') ? [{
+ boundary: 'fetch', method: 'GET', url: location.origin + (mode === 'prerequisite-invalid' ? '/bad-key' : '/key'),
+ requestBodyFormat: 'none', maxRequestBodyBytes: 0,
+ response: { statusCode: 200, url: location.origin + (mode === 'prerequisite-invalid' ? '/bad-key' : '/key'), bodyFormat: 'json', maxBodyBytes: 4096, requiredPaths: ['body.key'] },
+ }] : []
+ if (mode === 'controlled') document.querySelector('input').addEventListener('input', event => { savedState.password = event.target.value })
+ const metadata = {
+ id, name: mode, kind: transaction ? 'request-transaction' : 'business-closure', operation: mode,
+ origin: location.origin, lifecycle: 'document', execution: { resultMode: 'auto', timeoutMs: 1500 },
+ inputSlots: [{ id: 'body', name: 'body', index: 0, role: 'data', dataType: 'object', required: true, retained: false }],
+ output: { dataType: transaction ? 'object' : 'string', encoding: transaction ? 'json' : 'utf8', shape: transaction ? 'envelope' : 'value', paths: transaction ? ['body.password'] : [] },
+ provenance: {}, createdAt: Date.now(),
+ ...(transaction ? { transaction: { version: 2, prerequisites, inputMode: 'auto', request: {
+ boundary: mode === 'multipart-xhr' ? 'xhr' : mode === 'multipart-beacon' ? 'beacon' : 'fetch',
+ method: 'POST', url: endpoint, bodyFormat: mode.startsWith('multipart') ? 'form' : 'json', expectedDestinations: ['body.password'],
+ } } } : {}),
+ }
+ const registry = globalThis[key] ||= new Map()
+ registry.set(id, { metadata, invoke(args) {
+ if (mode === 'native-form') {
+ const form = document.querySelector('form'); form.method = 'POST'; form.action = endpoint
+ Reflect.apply(globalThis.cachedSubmit, form, []); return 'unexpected'
+ }
+ if (mode === 'cached') return globalThis.cachedFetch(endpoint, { method: 'POST', body: 'leak' }).then(() => 'bad')
+ if (mode === 'timer') { setTimeout(() => fetch(endpoint, { method: 'POST', body: 'leak' }), 100); return 'ok' }
+ const value = mode === 'stale' || mode === 'controlled' ? savedState : args[0]
+ const send = () => {
+ if (mode.startsWith('multipart')) {
+ const form = new FormData(); form.set('password', value.password)
+ if (mode === 'multipart-xhr') { const xhr = new XMLHttpRequest(); xhr.open('POST', endpoint); return xhr.send(form) }
+ if (mode === 'multipart-beacon') return navigator.sendBeacon(endpoint, form)
+ return fetch(endpoint, { method: 'POST', body: form })
+ }
+ return fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(value) })
+ }
+ if (mode.startsWith('concurrent')) return new Promise(resolve => setTimeout(resolve, 40)).then(send)
+ if (prerequisites.length) return fetch(mode === 'prerequisite-unplanned' ? '/unplanned' : prerequisites[0].url).then(response => response.json()).then(send)
+ return send()
+ } })
+ return id
+ }, { key: registryKey, mode, transaction })
+ }
+ const execute = (id, password = 'new') => request('callable.execute', { callableId: id, args: [{ password }] })
+ await assert.rejects(execute(await register('cached')), /fetch|网络|阻止|Failed/i)
+ assert.equal((await execute(await register('timer'))).value, 'ok')
+ await targetPage.waitForTimeout(160)
+ assert(!received.some(item => ['/cached', '/timer'].includes(item.url)))
+ await assert.rejects(execute(await register('stale', true)), /新明文|旧状态/)
+ for (const mode of ['controlled', 'multipart', 'multipart-xhr', 'multipart-beacon']) {
+ assert.deepEqual((await execute(await register(mode, true))).value, { password: 'new' })
+ assert.equal(await targetPage.locator('input').inputValue(), 'old')
+ }
+ const concurrent = await Promise.all([register('concurrent-a', true), register('concurrent-b', true)])
+ const results = await Promise.all(concurrent.map((id, index) => execute(id, `new-${index}`)))
+ assert.deepEqual(results.map(result => result.value), [{ password: 'new-0' }, { password: 'new-1' }])
+ assert(!received.some(item => ['/stale', '/controlled', '/multipart', '/multipart-xhr', '/multipart-beacon', '/concurrent-a', '/concurrent-b'].includes(item.url)))
+ assert.deepEqual((await execute(await register('prerequisite', true))).value, { password: 'new' })
+ assert.equal(received.filter(item => item.url === '/key').length, 1)
+ await assert.rejects(execute(await register('prerequisite-invalid', true)), /缺少目标字段/)
+ await assert.rejects(execute(await register('prerequisite-unplanned', true)), /未授权请求/)
+ assert(!received.some(item => item.url === '/unplanned' || item.method === 'POST' && item.url.startsWith('/prerequisite')))
+ assert.equal((await controlPage.evaluate(() => chrome.declarativeNetRequest.getSessionRules())).length, 0)
+ const nonWritableId = await register('timer')
+ await targetPage.evaluate(() => {
+ globalThis.beforeIsolation = { fetch, setTimeout, sendBeacon: navigator.sendBeacon }
+ Object.defineProperty(navigator, 'sendBeacon', { value: navigator.sendBeacon, writable: false, configurable: true })
+ })
+ await assert.rejects(execute(nonWritableId), /不能隔离页面边界/)
+ assert(await targetPage.evaluate(() => {
+ const restored = fetch === globalThis.beforeIsolation.fetch && setTimeout === globalThis.beforeIsolation.setTimeout
+ Object.defineProperty(navigator, 'sendBeacon', { value: globalThis.beforeIsolation.sendBeacon, writable: true, configurable: true })
+ return restored
+ }))
+ assert.equal((await controlPage.evaluate(() => chrome.declarativeNetRequest.getSessionRules())).length, 0)
+
+ // A strict arrow listener in a one-line script must resolve by its exact function location.
+ await targetPage.addScriptTag({ content: `document.querySelector('form').addEventListener('submit', e => { 'use strict'; e.preventDefault(); fetch('/strict', { method: 'POST', body: 'cipher' }) }); document.querySelector('form').addEventListener('submit', e => { 'use strict'; e.preventDefault() });` })
+ await request('deep.capture.start', { matcher: { kind: 'request', urlPattern: '/strict' } })
+ const submit = targetPage.locator('form button').click({ noWaitAfter: true, timeout: 20_000 })
+ const paused = await waitFor(controlPage, 'deep.capture.status', target, value => value.state === 'paused' && !value.pause.collecting)
+ const resolved = paused.pause.frames.filter(frame => frame.functionInspection?.resolution === 'event-listener')
+ assert.equal(resolved.length, 1, JSON.stringify(paused.pause))
+ assert(resolved[0].functionInspection.resolved)
+ await request('deep.capture.resume')
+ await submit
+ const nativeFormId = await register('native-form')
+ const blockedForm = targetPage.waitForEvent('requestfailed', { predicate: request => request.url().endsWith('/native-form'), timeout: 10_000 })
+ void blockedForm.catch(() => undefined)
+ let nativeFormError
+ await assert.rejects(execute(nativeFormId), error => { nativeFormError = error.message; return true })
+ const failedForm = await blockedForm.catch(error => { throw new Error(`${error.message}; execution=${nativeFormError}; url=${targetPage.url()}; received=${JSON.stringify(received)}`) })
+ assert(failedForm.failure().errorText.includes('BLOCKED_BY_CLIENT'))
+ assert(!received.some(item => item.url === '/native-form'))
+ console.log('Capture regressions passed: Request body, native network guard, timer cleanup, fresh input, controlled forms, multipart, concurrent callables, strict same-line listeners.')
+} finally {
+ await harness?.close()
+ await new Promise(resolve => server.close(resolve))
+}
diff --git a/scripts/verify-login-gateway.mjs b/scripts/verify-login-gateway.mjs
new file mode 100644
index 0000000..469df93
--- /dev/null
+++ b/scripts/verify-login-gateway.mjs
@@ -0,0 +1,103 @@
+import assert from 'node:assert/strict'
+import { createDecipheriv } from 'node:crypto'
+import { extensionRequest, launchBrowserAgentContractHarness, transformedFetchOptions, waitFor } from './browser-agent-contract-harness.mjs'
+
+const targetURL = process.env.LOGIN_TARGET || 'http://localhost:8080/crypto/sqli/aes-ecb/encrypt/login'
+function decrypt(envelope) {
+ const decipher = createDecipheriv('aes-128-cbc', Buffer.from(envelope.key, 'hex'), Buffer.from(envelope.iv, 'hex'))
+ return JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.message, 'base64')), decipher.final()]).toString())
+}
+let harness
+try {
+ harness = await launchBrowserAgentContractHarness({ profilePrefix: 'yakit-login-gateway-', targetURL })
+ const { targetPage, controlPage, tabId, extensionId } = harness
+ const target = { tabId, frameId: 0 }
+ let completedPosts = 0
+ let blockedPosts = 0
+ targetPage.on('response', response => { if (response.url() === targetURL && response.request().method() === 'POST') completedPosts++ })
+ targetPage.on('requestfailed', request => {
+ if (request.url() === targetURL && request.method() === 'POST' && request.failure()?.errorText.includes('BLOCKED_BY_CLIENT')) blockedPosts++
+ })
+ await extensionRequest(controlPage, 'recording.start', { ...target, captureValues: true, maxEntries: 300, maxValueBytes: 8192 })
+ await targetPage.locator('#username').fill('audit-original')
+ await targetPage.locator('#password').fill('original-wrong-password')
+ await targetPage.locator('button[type=submit]').click()
+ const recorded = await waitFor(controlPage, 'recording.get', target, snapshot =>
+ snapshot.profileCandidates.some(candidate => candidate.direction === 'response' && candidate.status === 'ready'))
+ await extensionRequest(controlPage, 'recording.stop', target)
+ assert.equal(completedPosts, 1)
+ const responseCandidate = recorded.profileCandidates.find(candidate => candidate.direction === 'response' && candidate.status === 'ready')
+ const requestCandidate = recorded.profileCandidates.find(candidate => candidate.direction === 'request' && candidate.transactionId === responseCandidate.transactionId)
+ assert.equal(requestCandidate.status, 'capture-required', JSON.stringify(recorded.profileCandidates))
+
+ await controlPage.goto(`chrome-extension://${extensionId}/options.html?tabId=${tabId}#gateway`)
+ await controlPage.evaluate(() => {
+ globalThis.captureActions = []
+ const send = chrome.runtime.sendMessage.bind(chrome.runtime)
+ chrome.runtime.sendMessage = (...args) => { globalThis.captureActions.push(args[0]); return send(...args) }
+ })
+ const traceIndex = recorded.traces.findIndex(trace => trace.id === responseCandidate.traceId)
+ await controlPage.locator('.recording-traces button').nth(traceIndex).click()
+ await controlPage.locator(`[data-event-id="${responseCandidate.source.eventId}"]`).click()
+ await controlPage.getByRole('button', { name: '继续捕获请求方向', exact: true }).click()
+ await waitFor(controlPage, 'deep.capture.status', target, status => status.state === 'armed')
+ let submitError
+ const submit = targetPage.locator('button[type=submit]').click({ noWaitAfter: true, timeout: 45_000 }).catch(error => { submitError = error })
+ let profiles
+ try {
+ profiles = await waitFor(controlPage, 'transform.profile.list', {}, values => values.some(profile => profile.request.enabled && profile.response.enabled), 30_000)
+ } catch (error) {
+ const status = await extensionRequest(controlPage, 'deep.capture.status', target)
+ if (status.pause) status.pause.frames = status.pause.frames.map(({ scopes, ...frame }) => frame)
+ const actions = await controlPage.evaluate(() => globalThis.captureActions.filter(item => /create|start|save/.test(item.action)))
+ throw new Error(`${error.message}\n${JSON.stringify(status)}\n${JSON.stringify(actions)}\n${await controlPage.locator('body').innerText()}`)
+ }
+ await submit
+ if (submitError) throw submitError
+ assert.equal(completedPosts, 1, 'the capture submission must not reach the server')
+ assert.equal(blockedPosts, 1, 'the browser must confirm cancellation of the capture submission')
+ const profile = profiles.find(value => value.request.enabled && value.response.enabled)
+ assert.equal(profiles.length, 1, 'both directions must share one gateway')
+
+ for (const plaintext of [
+ { username: 'audit-new-user', password: 'new-wrong-password' },
+ { username: 'audit-second-user', password: 'second-wrong-password' },
+ ]) {
+ const packet = { method: 'POST', url: targetURL, headers: [{ name: 'Content-Type', value: 'application/json' }], bodyBase64: Buffer.from(JSON.stringify(plaintext)).toString('base64') }
+ const encrypted = await extensionRequest(controlPage, 'transform.execute', { profileId: profile.id, direction: 'request', packet })
+ const envelope = JSON.parse(Buffer.from(encrypted.bodyBase64, 'base64').toString())
+ assert.deepEqual(decrypt(envelope), plaintext, 'the complete new input must be encrypted')
+ const response = await fetch(targetURL, transformedFetchOptions(encrypted, packet.headers))
+ const wireResponse = await response.text()
+ const decrypted = await extensionRequest(controlPage, 'transform.execute', {
+ profileId: profile.id, direction: 'response', packet: { ...packet, bodyBase64: Buffer.from(wireResponse).toString('base64') },
+ })
+ assert.deepEqual(JSON.parse(Buffer.from(decrypted.bodyBase64, 'base64').toString()), decrypt(JSON.parse(wireResponse)))
+ }
+ assert.equal(completedPosts, 1, 'local gateway replay must not send browser requests')
+ assert.equal(await targetPage.locator('#username').inputValue(), 'audit-original')
+ assert.equal(await targetPage.locator('#password').inputValue(), 'original-wrong-password')
+ // Also pause inside the native Fetch boundary, where changing window.fetch is too late.
+ await extensionRequest(controlPage, 'deep.capture.start', {
+ ...target, matcher: { kind: 'request', urlPattern: targetURL, frameHints: requestCandidate.capturePlan.frameHints },
+ })
+ const boundaryBlocked = targetPage.waitForEvent('requestfailed', {
+ predicate: request => request.url() === targetURL && request.failure()?.errorText.includes('BLOCKED_BY_CLIENT'),
+ timeout: 30_000,
+ })
+ void boundaryBlocked.catch(() => undefined)
+ const boundarySubmit = targetPage.locator('button[type=submit]').click({ noWaitAfter: true, timeout: 30_000 }).catch(error => { submitError = error })
+ const paused = await waitFor(controlPage, 'deep.capture.status', target, status => status.state === 'paused' && !status.pause.collecting)
+ assert.equal(paused.pause.automaticCapture.state, 'ready')
+ const boundaryCallable = await extensionRequest(controlPage, 'callable.create', {
+ ...target, source: 'deep-capture', strategy: 'request-transaction',
+ callFrameId: paused.pause.automaticCapture.frameId, candidateId: requestCandidate.id,
+ })
+ await boundarySubmit
+ await boundaryBlocked
+ if (submitError) throw submitError
+ assert.equal(completedPosts, 1)
+ assert.equal(blockedPosts, 2)
+ await extensionRequest(controlPage, 'callable.delete', { ...target, callableId: boundaryCallable.id })
+ console.log('Login gateway passed: one recording, automatic request capture, browser-confirmed cancellation, one bidirectional profile, two distinct plaintexts and real server response decryption.')
+} finally { await harness?.close() }
diff --git a/src/entrypoints/options/App.css b/src/entrypoints/options/App.css
index bad30c2..5354422 100644
--- a/src/entrypoints/options/App.css
+++ b/src/entrypoints/options/App.css
@@ -484,6 +484,8 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
.recording-pipeline-step button > span:nth-child(2) { min-width: 0; }
.recording-pipeline-step small, .recording-pipeline-step strong, .recording-pipeline-step em, .recording-pipeline-step b { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.recording-pipeline-step small { color: var(--muted); font-size: 10px; font-weight: 650; }
+.recording-pipeline-step small.is-request { color: var(--warning); }
+.recording-pipeline-step small.is-response { color: var(--primary); }
.recording-pipeline-step strong { margin-top: 2px; font-size: var(--text-sm); font-weight: 650; }
.recording-pipeline-step em { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); font-style: normal; }
.recording-pipeline-step b { margin-top: 4px; color: var(--warning); font-size: 10px; font-weight: 650; }
@@ -517,6 +519,12 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
.recording-evidence { border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.recording-evidence summary { padding: 9px 0; color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; cursor: pointer; }
.recording-evidence[open] { padding-bottom: 10px; }
+.recording-related-transform { padding: 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-subtle); }
+.recording-related-transform > div { min-width: 0; display: flex; align-items: center; gap: 8px; color: var(--primary); }
+.recording-related-transform > div span { min-width: 0; }
+.recording-related-transform strong, .recording-related-transform small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+.recording-related-transform strong { color: var(--foreground); font-size: var(--text-sm); }
+.recording-related-transform small { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); }
.profile-inference { padding: 11px 0 0; display: grid; gap: 10px; border-top: 2px solid var(--primary); }
.profile-inference.is-medium { border-top-color: var(--warning); }
.profile-inference.is-low { border-top-color: var(--border-strong); }
diff --git a/src/entrypoints/options/App.tsx b/src/entrypoints/options/App.tsx
index f2dd33f..ccdd3e3 100644
--- a/src/entrypoints/options/App.tsx
+++ b/src/entrypoints/options/App.tsx
@@ -682,7 +682,6 @@ function GatewayWorkspace({
run={run}
gatewayShared={bridge.state === 'connected'}
onShareGateway={shareTransform}
- initialMode="gateway"
/>
;
}
diff --git a/src/entrypoints/page-recorder-main-world.ts b/src/entrypoints/page-recorder-main-world.ts
index 027b7ca..37501f0 100644
--- a/src/entrypoints/page-recorder-main-world.ts
+++ b/src/entrypoints/page-recorder-main-world.ts
@@ -7,7 +7,7 @@ import {
type PageRecorderBridgeResponse,
} from '@/features/browser-recording/bridge-protocol';
import { PAGE_CALLABLE_REGISTRY_KEY } from '@/features/page-callable/constants';
-import { executeRequestTransaction, executeSideEffectFreeCallable } from '@/features/page-callable/request-transaction';
+import { executeRequestTransaction, executeSideEffectFreeCallable, observeCallableInput } from '@/features/page-callable/request-transaction';
import { callableExecutionPolicy, settleCallableResult } from '@/features/page-callable/execution';
import {
createCryptoAdapterRuntime,
@@ -200,8 +200,13 @@ interface RecordedCallHandle {
original: Function;
thisArg: unknown;
args: unknown[];
- inputIndex: number;
- originalInput: unknown;
+ replayInputs: Array<{
+ path: string;
+ name: string;
+ role: CallArgumentRole;
+ originalInput: unknown;
+ apply(args: unknown[], value: unknown): void;
+ }>;
eventId?: string;
traceId?: string;
recordingId?: string;
@@ -209,7 +214,6 @@ interface RecordedCallHandle {
outputDataType?: string;
outputEncoding?: PageCallableMetadata['output']['encoding'];
resultMode: 'sync' | 'promise';
- adaptInput(value: unknown): unknown;
}
interface RecorderController {
@@ -485,6 +489,7 @@ export default defineUnlistedScript(() => {
}
const started = performance.now();
const inputIndex = plan.inputIndex;
+ if (inputIndex >= 0) observeCallableInput(args[inputIndex]);
const callHandleId = plan.callableKind && inputIndex >= 0 ? registerHandle({
kind: plan.callableKind,
operation: `${plan.crypto.adapterId}.${plan.crypto.operation}`,
@@ -492,11 +497,17 @@ export default defineUnlistedScript(() => {
original,
thisArg,
args: [...args],
- inputIndex,
- originalInput: args[inputIndex],
+ replayInputs: plan.replayInputs || [{
+ path: '$input',
+ name: 'data',
+ role: 'data',
+ originalInput: args[inputIndex],
+ apply: (nextArgs, value) => {
+ nextArgs[inputIndex] = (plan.adaptInput || ((input) => defaultAdaptInput(input, args[inputIndex])))(value);
+ },
+ }],
outputEncoding: plan.outputEncoding || plan.crypto.outputEncoding,
resultMode: operation.resultMode,
- adaptInput: plan.adaptInput || ((value) => defaultAdaptInput(value, args[inputIndex])),
}) : undefined;
const item = observe(() => ({
kind: 'crypto',
@@ -728,8 +739,13 @@ export default defineUnlistedScript(() => {
return value;
}
- function createRecordedCallable(handle: RecordedCallHandle, name: string): PageCallableMetadata {
+ function createRecordedCallable(handle: RecordedCallHandle, name: string, inputPaths?: string[]): PageCallableMetadata {
const id = unique('callable');
+ const replayInputs = inputPaths?.length
+ ? inputPaths.map((path) => handle.replayInputs.find((item) => item.path === path))
+ : [handle.replayInputs[0]];
+ if (replayInputs.some((item) => !item)) throw new Error('页面调用不支持请求的动态输入');
+ const resolvedInputs = replayInputs as RecordedCallHandle['replayInputs'];
const metadata: PageCallableMetadata = {
id,
name: name.trim().slice(0, 120) || handle.operation,
@@ -740,15 +756,15 @@ export default defineUnlistedScript(() => {
origin: location.origin,
lifecycle: 'document',
execution: callableExecutionPolicy(handle.resultMode),
- inputSlots: [{
- id: 'data',
- name: 'data',
- index: 0,
- role: 'data',
- dataType: dataType(handle.originalInput),
+ inputSlots: resolvedInputs.map((input, index) => ({
+ id: input.name,
+ name: input.name,
+ index,
+ role: input.role,
+ dataType: dataType(input.originalInput),
required: true,
retained: false,
- }],
+ })),
output: {
dataType: handle.outputDataType || 'unknown',
encoding: handle.outputEncoding || 'auto',
@@ -767,9 +783,9 @@ export default defineUnlistedScript(() => {
pageCallableRegistry().set(id, {
metadata,
invoke(values) {
- if (!values.length) throw new Error('页面函数缺少 data 参数');
+ if (values.length < resolvedInputs.length) throw new Error(`页面函数需要 ${resolvedInputs.length} 个动态参数`);
const args = [...handle.args];
- args[handle.inputIndex] = handle.adaptInput(values[0]);
+ resolvedInputs.forEach((input, index) => input.apply(args, values[index]));
return Reflect.apply(handle.original, handle.thisArg, args);
},
});
@@ -786,6 +802,11 @@ export default defineUnlistedScript(() => {
logicalInput: values[0],
invoke: (context) => entry.invoke(values, context),
timeoutMs: entry.metadata.execution.timeoutMs,
+ observeInputs: () => {
+ const recording = active || Boolean(deepBreakMatcher);
+ cryptoAdapterRuntime.start();
+ return () => { if (!recording) cryptoAdapterRuntime.stop(); };
+ },
})
: entry.metadata.kind === 'business-closure' || entry.metadata.kind === 'global-function'
? await executeSideEffectFreeCallable(() => entry.invoke(values), entry.metadata.execution)
@@ -941,7 +962,11 @@ export default defineUnlistedScript(() => {
const callHandleId = String(input.callHandleId || '');
const handle = handles.get(callHandleId);
if (!handle) throw new Error('加解密调用句柄不存在或已经失效');
- return createRecordedCallable(handle, String(input.name || handle.operation));
+ return createRecordedCallable(
+ handle,
+ String(input.name || handle.operation),
+ Array.isArray(input.dynamicInputPaths) ? input.dynamicInputPaths.map(String) : undefined,
+ );
}
if (command === 'callable.list') return callableMetadata();
if (command === 'callable.execute') {
diff --git a/src/features/browser-analysis/service.test.ts b/src/features/browser-analysis/service.test.ts
index 8210d59..f019e51 100644
--- a/src/features/browser-analysis/service.test.ts
+++ b/src/features/browser-analysis/service.test.ts
@@ -13,6 +13,7 @@ vi.mock('wxt/browser', () => {
const event = { addListener: vi.fn() };
return {
browser: {
+ storage: {},
tabs: { onRemoved: event, onCreated: event },
webNavigation: {
onBeforeNavigate: event,
diff --git a/src/features/browser-analysis/service.ts b/src/features/browser-analysis/service.ts
index 99bae56..da86f35 100644
--- a/src/features/browser-analysis/service.ts
+++ b/src/features/browser-analysis/service.ts
@@ -9,12 +9,20 @@ import {
compileGuidedTransform,
parseGuidedTransform,
} from '@/features/browser-transform/guided';
-import { createBrowserTransformProfileInput } from '@/features/browser-transform/profile-draft';
+import {
+ createBrowserTransformProfileInput,
+ pairedBrowserTransformCandidate,
+} from '@/features/browser-transform/profile-draft';
import { validateBrowserTransformProfile } from '@/features/browser-transform/service';
import { executePageCallable, listPageCallables } from '@/features/page-callable/service';
import { browserTransformProfileInputSchema } from '@/protocol/transform';
import { ExtensionError } from '@/shared/errors';
import { createOpaqueId } from '@/shared/id';
+import { eventMatcher } from '@/features/deep-capture/matcher';
+import { createCapturedPageCallable, deepCaptureStatus, detachDeepCapture, startDeepCapture, type DeepCaptureOwner } from '@/features/deep-capture/service';
+import { withPageNetworkGuard } from '@/features/page-callable/network-guard';
+import { actOnPageNode, capturePageContext } from '@/features/page-context/service';
+import { beginPageDialogCapture, endPageDialogCapture } from '@/features/page-context/dialogs';
import {
BROWSER_TRANSFORM_AGENT_CONTRACT_VERSION,
type ActiveTabInfo,
@@ -36,6 +44,7 @@ import {
type BrowserTransformProfileValidationResult,
type BrowserTransformValidationDraft,
type BrowserTransformDirectionName,
+ type BrowserDeepCaptureMatcher,
} from '@/types/models';
const MAX_TRACE_EVENTS = 80;
@@ -67,6 +76,8 @@ let callableOutputStorageQueue: Promise = Promise.resolve();
interface BrowserProfileEvidenceReference {
candidate: BrowserProfileInferenceCandidate;
requestEvent?: BrowserRecordingEvent;
+ matcher?: BrowserDeepCaptureMatcher;
+ triggerKey?: string;
createdAt: number;
expiresAt: number;
}
@@ -136,7 +147,7 @@ function pruneProfileEvidence(
);
}
-export async function stageBrowserProfileEvidence(snapshot: BrowserRecordingSnapshot): Promise {
+export async function stageBrowserProfileEvidence(snapshot: BrowserRecordingSnapshot, triggerKey?: string): Promise {
if (!snapshot.profileCandidates.length) return;
const scoped = recordingSnapshotForScope(snapshot, false);
const events = new Map(scoped.events.map((event) => [event.id, event]));
@@ -147,6 +158,8 @@ export async function stageBrowserProfileEvidence(snapshot: BrowserRecordingSnap
stored[candidate.id] = {
candidate,
requestEvent: events.get(candidate.request.eventId),
+ matcher: eventMatcher(events.get(candidate.capturePlan?.matcherEventId || candidate.source.eventId), candidate),
+ triggerKey: triggerKey || stored[candidate.id]?.triggerKey,
createdAt,
expiresAt: createdAt + PROFILE_EVIDENCE_TTL_MS,
};
@@ -194,12 +207,14 @@ export async function resolveBrowserProfileCaptureTransaction(
}
function callableAnalysis(candidate: BrowserProfileInferenceCandidate): BrowserTransformCallableAnalysis {
+ const sources = [candidate.source, ...candidate.sources]
+ .filter((source, index, items) => items.findIndex((item) => item.eventId === source.eventId) === index);
return {
version: 1,
traceId: candidate.traceId,
confidence: { ...candidate.confidence },
flow: candidate.flow.slice(0, 32),
- operations: candidate.sources.slice(0, 16).map((source) => ({
+ operations: sources.slice(0, 16).map((source) => ({
operation: source.operation,
destination: source.destination,
crypto: source.crypto ? structuredClone(source.crypto) : undefined,
@@ -223,6 +238,13 @@ async function resolveStagedProfileCandidate(
return reference.candidate;
}
+async function resolvePairedStagedProfileCandidate(
+ candidate: BrowserProfileInferenceCandidate,
+): Promise {
+ const references = Object.values(pruneProfileEvidence(await readStoredProfileEvidence()));
+ return pairedBrowserTransformCandidate(references.map((item) => item.candidate), candidate, true);
+}
+
export async function resolveBrowserProfileCallableAnalysis(
target: BrowserTarget,
candidateId: string,
@@ -1072,6 +1094,21 @@ function originOf(value: string): string {
}
}
+function callableMatchesCandidate(
+ callable: BrowserPageCallable,
+ candidate: BrowserProfileInferenceCandidate,
+): boolean {
+ const sources = [candidate.source, ...candidate.sources]
+ .filter((source, index, items) => items.findIndex((item) => item.eventId === source.eventId) === index);
+ const sourceEventIds = new Set(sources.map((source) => source.eventId));
+ if (callable.provenance.eventId) return sourceEventIds.has(callable.provenance.eventId);
+ const analysis = callable.provenance.analysis;
+ return analysis?.traceId === candidate.traceId && sources.every((source) => (
+ analysis.operations.some((operation) => operation.operation === source.operation
+ && operation.destination === source.destination)
+ ));
+}
+
export async function proposeBrowserTransformProfile(
target: BrowserTarget,
candidateId: string,
@@ -1088,8 +1125,41 @@ export async function proposeBrowserTransformProfile(
]);
const evidence = await resolveProfileEvidence(snapshot, target, candidateId);
const candidate = evidence.candidate;
+ const pairedCandidate = pairedBrowserTransformCandidate(snapshot.profileCandidates, candidate, true)
+ || await resolvePairedStagedProfileCandidate(candidate);
const recordedCallable = callables.find((item) => item.id === callableId);
if (!recordedCallable) throw new ExtensionError('callable_unavailable', `页面函数已经失效: ${callableId}`);
+ if (candidate.status !== 'ready' && recordedCallable.kind === 'recorded-call') {
+ throw new ExtensionError(
+ 'gateway_capture_required',
+ candidate.missing[0]?.label || '当前候选依赖完整业务流程,不能直接复用单个页面加解密调用',
+ );
+ }
+ const recordedPairedCallable = pairedCandidate
+ ? callables.find((item) => item.id !== recordedCallable.id && callableMatchesCandidate(item, pairedCandidate))
+ : undefined;
+ if (pairedCandidate && pairedCandidate.status !== 'ready' && recordedPairedCallable?.kind === 'recorded-call') {
+ throw new ExtensionError(
+ 'gateway_capture_required',
+ pairedCandidate.missing[0]?.label || `配对的${pairedCandidate.direction === 'request' ? '请求' : '响应'}方向尚未完成捕获`,
+ );
+ }
+ if (pairedCandidate && !recordedPairedCallable) {
+ throw new ExtensionError(
+ 'gateway_pair_incomplete',
+ `已经识别同一事务的${pairedCandidate.direction === 'request' ? '请求' : '响应'}方向,但对应页面函数尚未创建`,
+ );
+ }
+ const pairedObservation = pairedCandidate?.direction === 'request' && recordedPairedCallable
+ ? await callableOutputObservation(recordedPairedCallable)
+ : undefined;
+ const pairedCallable = pairedObservation && recordedPairedCallable
+ ? promoteObservedEnvelopeCallable(
+ recordedPairedCallable,
+ snapshot.events.find((item) => item.id === pairedCandidate!.request.eventId),
+ pairedObservation.objectKeys,
+ )
+ : recordedPairedCallable;
const observation = candidate.direction === 'request'
? await callableOutputObservation(recordedCallable)
: undefined;
@@ -1099,7 +1169,7 @@ export async function proposeBrowserTransformProfile(
if (!sameEvidenceTarget(candidate, target)) {
throw new ExtensionError('target_denied', '自动推断候选不属于当前共享页面');
}
- if (callable.provenance.traceId && callable.provenance.traceId !== candidate.traceId) {
+ if (!callableMatchesCandidate(callable, candidate)) {
throw new ExtensionError('profile_evidence_mismatch', '页面函数与自动推断候选不属于同一条业务 Trace');
}
const pageUrl = frame?.url || callable.origin;
@@ -1116,7 +1186,14 @@ export async function proposeBrowserTransformProfile(
lastAccessed: tab.lastAccessed,
};
const requestEvent = evidence.requestEvent;
- let profile = createBrowserTransformProfileInput(tabInfo, requestEvent, callable, candidate, inputPaths ? undefined : packet);
+ let profile = createBrowserTransformProfileInput(
+ tabInfo,
+ requestEvent,
+ callable,
+ candidate,
+ inputPaths ? undefined : packet,
+ pairedCandidate && pairedCallable ? { candidate: pairedCandidate, callable: pairedCallable } : undefined,
+ );
profile = {
...profile,
name: name || profile.name,
@@ -1193,15 +1270,17 @@ export async function validateBrowserTransformProposal(
comparisonMode: 'structure' | 'exact' = 'structure',
candidateId?: string,
): Promise {
- const { profile: normalized, execution } = await validateBrowserTransformProfile(profile, packet);
- const generated = applyTransformExecution(packet, execution);
const candidate = candidateId
? (await resolveProfileEvidence(
- await getBrowserRecording(normalized.target, 500, false),
- normalized.target,
+ await getBrowserRecording(profile.target, 500, false),
+ profile.target,
candidateId,
)).candidate
: undefined;
+ const { profile: normalized, execution } = await validateBrowserTransformProfile(profile, packet, {
+ direction: candidate?.direction,
+ });
+ const generated = applyTransformExecution(packet, execution);
if (candidate && !sameEvidenceTarget(candidate, normalized.target)) {
throw new ExtensionError('profile_evidence_mismatch', '验证候选不属于明文网关绑定的页面');
}
@@ -1237,6 +1316,7 @@ export async function validateBrowserTransformProposal(
id: validationDraft.id,
createdAt: validationDraft.createdAt,
expiresAt: validationDraft.expiresAt,
+ directions: { request: normalizedProfile.request.enabled, response: normalizedProfile.response.enabled },
} : undefined,
next: comparison
? comparison.equivalent
@@ -1275,34 +1355,107 @@ export async function validateInferredBrowserTransformProfile(
);
}
+const preparingTabs = new Set();
+
+interface PreparationCapture {
+ owner: DeepCaptureOwner;
+ trigger?: { captureId: string; nodeId: string };
+ authorize(): void;
+}
+
+async function captureMissingProfileCallable(
+ target: BrowserTarget,
+ candidate: BrowserProfileInferenceCandidate,
+ options?: PreparationCapture,
+): Promise {
+ if (candidate.status !== 'capture-required' || !options) {
+ throw new ExtensionError('gateway_capture_required', candidate.missing[0]?.label || '候选缺少可安全捕获的业务边界');
+ }
+ options.authorize();
+ const reference = pruneProfileEvidence(await readStoredProfileEvidence())[candidate.id];
+ if (!reference?.matcher) throw new ExtensionError('gateway_capture_required', '候选缺少断点证据,请重新执行 browser.crypto.inspect');
+ let trigger = options.trigger;
+ if (!trigger && reference.triggerKey) {
+ const context = await capturePageContext({ includeDom: true }, target);
+ const nodes = context.document.interactive.filter((node) => node.semanticKey === reference.triggerKey);
+ if (nodes.length === 1) trigger = { captureId: context.captureId, nodeId: nodes[0].nodeId };
+ }
+ if (!trigger) throw new ExtensionError('gateway_trigger_required', '无法唯一定位原操作,请用 browser.context 获取新的触发节点,并向 browser.transform.prepare 传入 trigger');
+ const current = await deepCaptureStatus(target, options.owner);
+ if (['attached', 'armed', 'paused'].includes(current.state)) throw new ExtensionError('capture_busy', '当前页面已有深度捕获会话,请先完成或释放它');
+ const transaction = candidate.direction === 'request' ? (await resolveBrowserProfileCaptureContext(target, candidate.id)).transaction : undefined;
+ return withPageNetworkGuard(target, transaction?.prerequisites || [], async () => {
+ const dialogOwned = await beginPageDialogCapture(target);
+ let started = false;
+ try {
+ await startDeepCapture(target, reference.matcher!, options.owner);
+ started = true;
+ await actOnPageNode(trigger!.captureId, trigger!.nodeId, 'click', target);
+ const deadline = Math.min(Date.now() + 15_000, options.owner.kind === 'grant' ? options.owner.expiresAt : Infinity);
+ while (Date.now() < deadline) {
+ const status = await deepCaptureStatus(target, options.owner);
+ if (status.state === 'paused' && status.pause && !status.pause.collecting) {
+ const automatic = status.pause.automaticCapture;
+ if (automatic?.state !== 'ready' || !automatic.frameId) {
+ throw new ExtensionError('gateway_capture_ambiguous', automatic?.reason || '暂停现场没有唯一可复用的业务函数', { automaticCapture: automatic });
+ }
+ const analysis = callableAnalysis(candidate);
+ if (automatic.strategy === 'request-transaction') {
+ if (!transaction) throw new ExtensionError('gateway_capture_required', '该方向没有请求事务证据');
+ return await createCapturedPageCallable(target, automatic.frameId, { strategy: 'request-transaction', transaction, analysis }, options.owner);
+ }
+ return await createCapturedPageCallable(target, automatic.frameId, { strategy: 'selected-frame', analysis }, options.owner);
+ }
+ if (!['armed', 'paused', 'attached'].includes(status.state)) throw new ExtensionError('gateway_capture_failed', status.error || '捕获会话已结束,未获取业务函数');
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ throw new ExtensionError('gateway_capture_timeout', '重触发操作后未命中预期业务边界,请检查当前页面状态');
+ } finally {
+ try { if (started) await detachDeepCapture(target, options.owner); }
+ finally { await endPageDialogCapture(target, dialogOwned); }
+ }
+ }, candidate.direction === 'request' ? candidate.request.url : undefined);
+}
+
export async function prepareCapturedBrowserTransformProfile(
target: BrowserTarget,
candidateId: string,
packet: BrowserTransformPacket,
inputPaths?: string[],
name?: string,
+ capture?: PreparationCapture,
): Promise {
- const candidate = await resolveStagedProfileCandidate(target, candidateId);
- const source = [candidate.source, ...candidate.sources]
- .find((item) => item.callHandleId);
- if (!source?.callHandleId) {
- throw new ExtensionError(
- 'gateway_capture_required',
- '本次操作没有捕获到可回放的页面函数,请在原页面重新执行一次浏览器加解密检查',
- );
+ if (preparingTabs.has(target.tabId)) throw new ExtensionError('capture_busy', '当前标签页正在准备网关,请等待该操作完成');
+ preparingTabs.add(target.tabId);
+ try {
+ const candidate = await resolveStagedProfileCandidate(target, candidateId);
+ const pairedCandidate = await resolvePairedStagedProfileCandidate(candidate);
+ const existingCallables = await listPageCallables(target);
+ const ensureCallable = async (item: BrowserProfileInferenceCandidate) => {
+ const source = [item.source, ...item.sources].find((value) => value.callHandleId) || item.source;
+ const existing = existingCallables.find((callable) => callableMatchesCandidate(callable, item)
+ && (item.status === 'ready'
+ ? callable.inputSlots.filter((slot) => !slot.retained).length === (source.dynamicInputPaths?.length || 1)
+ : callable.kind !== 'recorded-call'));
+ if (existing) return existing;
+ if (item.status !== 'ready') return captureMissingProfileCallable(target, item, capture);
+ if (!source.callHandleId) throw new ExtensionError('gateway_capture_required', '候选缺少可复用的页面调用句柄,请重新执行 browser.crypto.inspect');
+ return createRecordedPageCallable(target, {
+ callHandleId: source.callHandleId,
+ name: name || item.summary.slice(0, 120) || 'Captured page transform',
+ dynamicInputPaths: source.dynamicInputPaths,
+ });
+ };
+ // Retain an already-recorded opposite direction before re-triggering the page.
+ const directions = [candidate, pairedCandidate].filter((item): item is BrowserProfileInferenceCandidate => Boolean(item))
+ .sort((left, right) => Number(right.status === 'ready') - Number(left.status === 'ready'));
+ let callable: BrowserPageCallable | undefined;
+ for (const direction of directions) {
+ const created = await ensureCallable(direction);
+ if (direction.id === candidate.id) callable = created;
+ }
+ return await validateInferredBrowserTransformProfile(target, candidate.id, callable!.id, packet, inputPaths, name);
+ } finally {
+ preparingTabs.delete(target.tabId);
}
- const existing = (await listPageCallables(target))
- .find((item) => item.provenance.eventId === source.eventId);
- const callable = existing || await createRecordedPageCallable(target, {
- callHandleId: source.callHandleId,
- name: name || candidate.summary.slice(0, 120) || 'Captured page transform',
- });
- return validateInferredBrowserTransformProfile(
- target,
- candidate.id,
- callable.id,
- packet,
- inputPaths,
- name,
- );
}
diff --git a/src/features/browser-crypto/adapters/adapters.test.ts b/src/features/browser-crypto/adapters/adapters.test.ts
index a26b142..bb6ea05 100644
--- a/src/features/browser-crypto/adapters/adapters.test.ts
+++ b/src/features/browser-crypto/adapters/adapters.test.ts
@@ -91,11 +91,15 @@ describe('page crypto adapters', () => {
const CBC = {};
const Pkcs7 = {};
const parsed: string[] = [];
+ const hexParsed: string[] = [];
const cryptoJs = {
- AES: { encrypt() { return 'cipher'; } },
+ AES: { encrypt() { return 'cipher'; }, decrypt() { return 'plain'; } },
mode: { CBC },
pad: { Pkcs7 },
- enc: { Base64: { parse(value: string) { parsed.push(value); return { wordArray: value }; } } },
+ enc: {
+ Base64: { parse(value: string) { parsed.push(value); return { wordArray: value }; } },
+ Hex: { parse(value: string) { hexParsed.push(value); return { hexWordArray: value }; } },
+ },
};
const scope = { window: { CryptoJS: cryptoJs } as unknown as Window } satisfies CryptoAdapterScope;
const encrypt = cryptoJsAdapter.discover(scope).find((item) => item.operation === 'AES.encrypt');
@@ -117,6 +121,28 @@ describe('page crypto adapters', () => {
]);
expect(plan?.adaptInput?.(new Uint8Array([4, 5, 6]))).toEqual({ wordArray: 'base64:4,5,6' });
expect(parsed).toEqual(['base64:4,5,6']);
+
+ const decrypt = cryptoJsAdapter.discover(scope).find((item) => item.operation === 'AES.decrypt');
+ const decryptPlan = decrypt?.describe(cryptoJs.AES, [
+ 'cipher',
+ { sigBytes: 16 },
+ { mode: CBC, padding: Pkcs7, iv: { sigBytes: 16 } },
+ ], toolkit());
+ expect(decryptPlan?.crypto.outputEncoding).toBe('hex');
+ expect(decryptPlan?.outputEncoding).toBe('hex');
+ expect(decryptPlan?.replayInputs?.map((input) => input.path)).toEqual([
+ '$input', '$input.key', '$input.iv',
+ ]);
+ const replayArgs: unknown[] = ['old-cipher', { oldKey: true }, { mode: CBC, padding: Pkcs7, iv: { oldIv: true } }];
+ decryptPlan?.replayInputs?.[0].apply(replayArgs, 'new-cipher');
+ decryptPlan?.replayInputs?.[1].apply(replayArgs, '00112233');
+ decryptPlan?.replayInputs?.[2].apply(replayArgs, 'aabbccdd');
+ expect(replayArgs).toEqual([
+ 'new-cipher',
+ { hexWordArray: '00112233' },
+ { mode: CBC, padding: Pkcs7, iv: { hexWordArray: 'aabbccdd' } },
+ ]);
+ expect(hexParsed).toEqual(['00112233', 'aabbccdd']);
});
it('retains only bounded JSEncrypt receiver metadata', () => {
diff --git a/src/features/browser-crypto/adapters/contract.ts b/src/features/browser-crypto/adapters/contract.ts
index c639e6b..613b0dc 100644
--- a/src/features/browser-crypto/adapters/contract.ts
+++ b/src/features/browser-crypto/adapters/contract.ts
@@ -47,6 +47,13 @@ export interface CryptoAdapterInvocationPlan {
arguments: BrowserRecordingCallArgument[];
callableKind?: CallableOperationKind;
outputEncoding?: BrowserPageCallableValueEncoding;
+ replayInputs?: Array<{
+ path: string;
+ name: string;
+ role: BrowserRecordingCallArgument['role'];
+ originalInput: unknown;
+ apply(args: unknown[], value: unknown): void;
+ }>;
inputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
outputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
outputError?(value: unknown): string | undefined;
diff --git a/src/features/browser-crypto/adapters/cryptojs.ts b/src/features/browser-crypto/adapters/cryptojs.ts
index 7a41e2c..24ec86e 100644
--- a/src/features/browser-crypto/adapters/cryptojs.ts
+++ b/src/features/browser-crypto/adapters/cryptojs.ts
@@ -60,6 +60,8 @@ function describe(
const cryptoJs = (scope.window as unknown as { CryptoJS?: Record }).CryptoJS || {};
const normalized = path.toLowerCase();
const encrypting = normalized.includes('encrypt');
+ const decrypting = normalized.includes('decrypt');
+ const outputEncoding = encrypting ? 'base64' : decrypting ? 'hex' : 'auto';
const options = normalized.includes('encrypt') || normalized.includes('decrypt')
? optionsMetadata(cryptoJs, args[2], toolkit)
: {};
@@ -68,6 +70,33 @@ function describe(
else if (normalized.includes('pbkdf2') || normalized.includes('evpkdf')) roles = ['data', 'salt', 'options'];
else if (normalized.includes('.encrypt') || normalized.includes('.decrypt')) roles = ['data', 'key', 'options'];
const callableKind = callableOperationKind(path);
+ const adaptData = (value: unknown) => {
+ const originalInput = args[0];
+ if (originalInput && typeof originalInput === 'object'
+ && typeof (originalInput as { sigBytes?: unknown }).sigBytes === 'number') {
+ const bytes = toolkit.bytesForInput(value);
+ const encoder = (cryptoJs as { enc?: { Base64?: { parse?(input: string): unknown } } }).enc?.Base64;
+ if (bytes && typeof encoder?.parse === 'function') return encoder.parse(toolkit.bytesToBase64(bytes));
+ }
+ return toolkit.defaultAdaptInput(value, originalInput);
+ };
+ const adaptWordArray = (value: unknown, originalInput: unknown) => {
+ if (!originalInput || typeof originalInput !== 'object'
+ || typeof (originalInput as { sigBytes?: unknown }).sigBytes !== 'number') {
+ return toolkit.defaultAdaptInput(value, originalInput);
+ }
+ const enc = (cryptoJs as {
+ enc?: {
+ Hex?: { parse?(input: string): unknown };
+ Base64?: { parse?(input: string): unknown };
+ };
+ }).enc;
+ if (typeof value === 'string' && /^[0-9a-f]+$/i.test(value) && value.length % 2 === 0
+ && typeof enc?.Hex?.parse === 'function') return enc.Hex.parse(value);
+ const bytes = toolkit.bytesForInput(value);
+ if (bytes && typeof enc?.Base64?.parse === 'function') return enc.Base64.parse(toolkit.bytesToBase64(bytes));
+ return toolkit.defaultAdaptInput(value, originalInput);
+ };
return {
crypto: {
adapterId: cryptoJsManifest.id,
@@ -78,12 +107,31 @@ function describe(
mode: options.mode,
padding: options.padding,
inputEncoding: 'auto',
- outputEncoding: encrypting ? 'base64' : 'auto',
+ outputEncoding,
state: { model: 'stateless', phase: 'one-shot' },
},
inputIndex: 0,
callableKind,
- outputEncoding: encrypting ? 'base64' : 'auto',
+ outputEncoding,
+ replayInputs: decrypting ? [
+ {
+ path: '$input', name: 'data', role: 'data', originalInput: args[0],
+ apply: (nextArgs, value) => { nextArgs[0] = adaptData(value); },
+ },
+ {
+ path: '$input.key', name: 'key', role: 'key', originalInput: args[1],
+ apply: (nextArgs, value) => { nextArgs[1] = adaptWordArray(value, args[1]); },
+ },
+ {
+ path: '$input.iv', name: 'iv', role: 'iv', originalInput: ownValue(args[2], 'iv'),
+ apply: (nextArgs, value) => {
+ const options = nextArgs[2] && typeof nextArgs[2] === 'object'
+ ? nextArgs[2] as Record
+ : {};
+ nextArgs[2] = { ...options, iv: adaptWordArray(value, ownValue(args[2], 'iv')) };
+ },
+ },
+ ] : undefined,
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
index,
roles[index] || 'unknown',
@@ -121,14 +169,7 @@ function describe(
return output.slice(0, 48);
},
adaptInput(value) {
- const originalInput = args[0];
- if (originalInput && typeof originalInput === 'object'
- && typeof (originalInput as { sigBytes?: unknown }).sigBytes === 'number') {
- const bytes = toolkit.bytesForInput(value);
- const encoder = (cryptoJs as { enc?: { Base64?: { parse?(input: string): unknown } } }).enc?.Base64;
- if (bytes && typeof encoder?.parse === 'function') return encoder.parse(toolkit.bytesToBase64(bytes));
- }
- return toolkit.defaultAdaptInput(value, originalInput);
+ return adaptData(value);
},
};
}
diff --git a/src/features/browser-crypto/inspect.test.ts b/src/features/browser-crypto/inspect.test.ts
index b544b30..ceb1018 100644
--- a/src/features/browser-crypto/inspect.test.ts
+++ b/src/features/browser-crypto/inspect.test.ts
@@ -53,6 +53,7 @@ describe('atomic page crypto inspection', () => {
}],
traces: [], links: [], callables: [], profileCandidates: [{
id: 'candidate-1', direction: 'request', summary: 'login request',
+ status: 'ready',
confidence: { score: 0.95, level: 'high' },
source: { eventId: 'event-1', callHandleId: 'handle-1' },
sources: [],
@@ -117,6 +118,35 @@ describe('atomic page crypto inspection', () => {
expect(fixture.context).toHaveBeenCalledWith({ includeDom: true }, { tabId: 7, frameId: 0 });
});
+ it('prepares a response-only protocol when no request transform was observed', async () => {
+ fixture.stopRecording.mockResolvedValueOnce({
+ status: { target: { tabId: 7, frameId: 0, documentId: 'doc-1' }, active: false, documentAvailable: true, count: 1, droppedCount: 0 },
+ events: [{
+ id: 'decrypt-1', sequence: 1, timestamp: 1, recordingId: 'recording-1', traceId: 'trace-1',
+ kind: 'crypto', operation: 'AES.decrypt', inputs: [], outputs: [], sensitiveCaptured: true,
+ }],
+ traces: [], links: [], callables: [], profileCandidates: [{
+ id: 'candidate-response', recordingId: 'recording-1', traceId: 'trace-1', direction: 'response',
+ status: 'ready', confidence: { score: 100, level: 'high' },
+ source: { eventId: 'decrypt-1', callHandleId: 'handle-1' }, sources: [],
+ request: { method: 'POST', url: 'https://example.test/api', bodyFormat: 'json', mappings: [] },
+ }],
+ });
+ const { inspectPageCryptoOperation } = await import('./inspect');
+
+ const result = await inspectPageCryptoOperation(
+ { tabId: 7, frameId: 0, documentId: 'doc-1' },
+ { captureId: 'capture-1', nodeId: 'n1', settleMs: 250 },
+ { grantId: 'paired', expiresAt: Date.now() + 60_000 },
+ );
+
+ expect(result.gatewayPreparation).toMatchObject({
+ state: 'ready',
+ direction: 'response',
+ directions: { request: { status: 'absent' }, response: { candidateId: 'candidate-response', status: 'ready' } },
+ });
+ });
+
it('waits for a delayed request instead of treating an empty capture as idle', async () => {
vi.useFakeTimers();
const startedAt = Date.now();
diff --git a/src/features/browser-crypto/inspect.ts b/src/features/browser-crypto/inspect.ts
index 6662f83..a136f45 100644
--- a/src/features/browser-crypto/inspect.ts
+++ b/src/features/browser-crypto/inspect.ts
@@ -19,6 +19,7 @@ import {
restorePageDialogCapture,
} from '@/features/page-context/dialogs';
import { ExtensionError } from '@/shared/errors';
+import { pairedBrowserTransformCandidate } from '@/features/browser-transform/profile-draft';
import type {
BrowserRecordingEvent,
BrowserRecordingSnapshot,
@@ -219,11 +220,23 @@ export async function inspectPageCryptoOperation(
if (!snapshot || !action) {
throw new ExtensionError('crypto_inspection_incomplete', '未能完整执行页面加解密检查');
}
- await stageBrowserProfileEvidence(snapshot);
+ await stageBrowserProfileEvidence(snapshot, action.node.semanticKey);
const preparation = snapshot.profileCandidates
- .filter((candidate) => candidate.direction === 'request' && [candidate.source, ...candidate.sources]
+ .filter((candidate) => [candidate.source, ...candidate.sources]
.some((source) => Boolean(source.callHandleId)))
- .sort((left, right) => right.confidence.score - left.confidence.score)[0];
+ .sort((left, right) => Number(right.direction === 'request') - Number(left.direction === 'request')
+ || right.confidence.score - left.confidence.score)[0];
+ const pairedPreparation = preparation
+ ? pairedBrowserTransformCandidate(snapshot.profileCandidates, preparation, true)
+ : undefined;
+ const directions = [preparation, pairedPreparation].filter(
+ (candidate): candidate is NonNullable => Boolean(candidate),
+ );
+ const requestPreparation = directions.find((candidate) => candidate.direction === 'request');
+ const responsePreparation = directions.find((candidate) => candidate.direction === 'response');
+ const preparationReady = Boolean(preparation?.status === 'ready'
+ && directions.every((candidate) => candidate.status === 'ready'
+ && [candidate.source, ...candidate.sources].some((source) => Boolean(source.callHandleId))));
const evidence = summarizeCryptoInspection(snapshot, requests);
return {
version: 1,
@@ -243,17 +256,27 @@ export async function inspectPageCryptoOperation(
},
postAction,
gatewayPreparation: preparation ? {
- state: 'ready',
+ state: preparationReady ? 'ready' : 'capture-required',
candidateId: preparation.id,
direction: preparation.direction,
confidence: preparation.confidence,
+ directions: {
+ request: requestPreparation
+ ? { candidateId: requestPreparation.id, status: requestPreparation.status }
+ : { status: 'absent' },
+ response: responsePreparation
+ ? { candidateId: responsePreparation.id, status: responsePreparation.status }
+ : { status: 'absent' },
+ },
request: {
method: preparation.request.method,
url: preparation.request.url,
bodyFormat: preparation.request.bodyFormat,
destinations: preparation.request.mappings.map((mapping) => mapping.destination).filter(Boolean),
},
- next: '需要明文 HTTP 测试时,直接调用 browser.transform.prepare;不要再调用 recording、callable、debugger 或 profile 底层能力',
+ next: preparationReady
+ ? '需要明文 HTTP 测试时,直接调用 browser.transform.prepare;同一事务的请求与响应会编译进一个 Profile'
+ : '调用 browser.transform.prepare,插件将自动重触发本次操作、捕获缺失的业务方向并验证完整网关;不需要打开插件 UI',
} : {
state: 'unavailable',
next: '本次证据可用于分析,但不足以生成明文转换;继续使用当前页面,不要重新打开网站',
diff --git a/src/features/browser-inference/inference.test.ts b/src/features/browser-inference/inference.test.ts
index 6c740ac..cbec0b1 100644
--- a/src/features/browser-inference/inference.test.ts
+++ b/src/features/browser-inference/inference.test.ts
@@ -6,6 +6,7 @@ import type {
} from '@/types/models';
import { inferBrowserTransformProfiles } from './inference';
import { buildRecordingLinks } from '@/features/browser-recording/timeline';
+import { pairedBrowserTransformCandidate } from '@/features/browser-transform/profile-draft';
function event(overrides: Partial & Pick): BrowserRecordingEvent {
return {
@@ -606,4 +607,112 @@ describe('browser profile inference', () => {
}));
expect(candidate.aiContext.requiredDecision).toBe('none');
});
+
+ it('replays response ciphertext, key, and iv as dynamic CryptoJS decrypt inputs', () => {
+ const response = event({
+ id: 'encrypted-response-dynamic', sequence: 1, kind: 'fetch', operation: 'response',
+ direction: 'receive', method: 'POST', url: 'https://example.test/crypto/login', statusCode: 200,
+ outputs: [
+ { path: '$body:json.message', fingerprint: 'cipher', encoding: 'text', byteLength: 88 },
+ { path: '$body:json.key', fingerprint: 'key-hex', encoding: 'hex', byteLength: 32 },
+ { path: '$body:json.iv', fingerprint: 'iv-hex', encoding: 'hex', byteLength: 32 },
+ ],
+ });
+ const key = event({
+ id: 'parse-key', sequence: 2, kind: 'transform', operation: 'Hex.parse',
+ inputs: [{ path: '$input', fingerprint: 'key-hex', encoding: 'hex', byteLength: 32 }],
+ outputs: [{ path: '$output', fingerprint: 'key-word-array', encoding: 'bytes', byteLength: 16 }],
+ });
+ const iv = event({
+ id: 'parse-iv', sequence: 3, kind: 'transform', operation: 'Hex.parse',
+ inputs: [{ path: '$input', fingerprint: 'iv-hex', encoding: 'hex', byteLength: 32 }],
+ outputs: [{ path: '$output', fingerprint: 'iv-word-array', encoding: 'bytes', byteLength: 16 }],
+ });
+ const decrypt = event({
+ id: 'decrypt-dynamic-response', sequence: 4, kind: 'crypto', operation: 'AES.decrypt',
+ crypto: cryptoJsAESDecrypt,
+ callHandleId: 'decrypt-dynamic-handle', callableCapable: true, arguments: safeArguments,
+ inputs: [
+ { path: '$input', fingerprint: 'cipher', encoding: 'text', byteLength: 88 },
+ { path: '$input.key', fingerprint: 'key-word-array', encoding: 'bytes', byteLength: 16 },
+ { path: '$input.iv', fingerprint: 'iv-word-array', encoding: 'bytes', byteLength: 16 },
+ ],
+ outputs: [{ path: '$output', fingerprint: 'plain', encoding: 'hex', byteLength: 42 }],
+ });
+ const events = [response, key, iv, decrypt];
+ const [candidate] = inferBrowserTransformProfiles({
+ target: { tabId: 7, frameId: 0, documentId: 'document-1' },
+ events,
+ links: buildRecordingLinks(events),
+ });
+
+ expect(candidate).toMatchObject({
+ direction: 'response',
+ status: 'ready',
+ source: { dynamicInputPaths: ['$input', '$input.key', '$input.iv'] },
+ request: {
+ mappings: [
+ { destination: 'body.message' },
+ { destination: 'body.key' },
+ { destination: 'body.iv' },
+ ],
+ },
+ });
+ expect(candidate.missing).toEqual([]);
+ expect(candidate.aiContext.requiredDecision).toBe('none');
+
+ const withoutCipherLink = inferBrowserTransformProfiles({
+ target: { tabId: 7, frameId: 0, documentId: 'document-1' },
+ events: [
+ { ...response, outputs: response.outputs.filter((output) => output.path !== '$body:json.message') },
+ key,
+ iv,
+ { ...decrypt, inputs: decrypt.inputs.filter((input) => input.path !== '$input') },
+ ],
+ links: buildRecordingLinks([
+ { ...response, outputs: response.outputs.filter((output) => output.path !== '$body:json.message') },
+ key,
+ iv,
+ { ...decrypt, inputs: decrypt.inputs.filter((input) => input.path !== '$input') },
+ ]),
+ })[0];
+ expect(withoutCipherLink).toMatchObject({ direction: 'response', status: 'capture-required' });
+ });
+
+ it('pairs request and response candidates by the browser network transaction', () => {
+ const encrypt = event({
+ id: 'encrypt-transaction', sequence: 1, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
+ callHandleId: 'encrypt-handle', callableCapable: true, arguments: safeArguments,
+ inputs: [{ path: '$input', fingerprint: 'plain-request', encoding: 'text', byteLength: 20 }],
+ outputs: [{ path: '$output:string', fingerprint: 'cipher-request', encoding: 'text', byteLength: 44 }],
+ });
+ const request = event({
+ id: 'request-transaction', sequence: 2, kind: 'fetch', operation: 'request', direction: 'send',
+ channelId: 'fetch-transaction-1', method: 'POST', url: 'https://example.test/login',
+ inputs: [{ path: '$body:json.message', fingerprint: 'cipher-request', encoding: 'text', byteLength: 44 }],
+ });
+ const response = event({
+ id: 'response-transaction', sequence: 3, kind: 'fetch', operation: 'response', direction: 'receive',
+ channelId: 'fetch-transaction-1', method: 'POST', url: 'https://example.test/login', statusCode: 200,
+ outputs: [{ path: '$body:json.message', fingerprint: 'cipher-response', encoding: 'text', byteLength: 44 }],
+ });
+ const decrypt = event({
+ id: 'decrypt-transaction', sequence: 4, kind: 'crypto', operation: 'AES.decrypt', crypto: cryptoJsAESDecrypt,
+ callHandleId: 'decrypt-handle', callableCapable: true, arguments: safeArguments,
+ inputs: [{ path: '$input', fingerprint: 'cipher-response', encoding: 'text', byteLength: 44 }],
+ outputs: [{ path: '$output', fingerprint: 'plain-response', encoding: 'hex', byteLength: 20 }],
+ });
+ const events = [encrypt, request, response, decrypt];
+ const candidates = inferBrowserTransformProfiles({
+ target: { tabId: 7, frameId: 0, documentId: 'document-1' },
+ events,
+ links: buildRecordingLinks(events),
+ });
+ const requestCandidate = candidates.find((candidate) => candidate.direction === 'request')!;
+ const responseCandidate = candidates.find((candidate) => candidate.direction === 'response')!;
+
+ expect(requestCandidate.transactionId).toBe('fetch-transaction-1');
+ expect(responseCandidate.transactionId).toBe('fetch-transaction-1');
+ expect(pairedBrowserTransformCandidate(candidates, requestCandidate)?.id).toBe(responseCandidate.id);
+ });
});
diff --git a/src/features/browser-inference/inference.ts b/src/features/browser-inference/inference.ts
index f7f982d..2fa070d 100644
--- a/src/features/browser-inference/inference.ts
+++ b/src/features/browser-inference/inference.ts
@@ -562,6 +562,7 @@ function buildCandidate(
id: candidateId,
recordingId: request.recordingId,
traceId: request.traceId,
+ transactionId: request.channelId,
target: { ...target },
direction: 'request',
request: {
@@ -698,6 +699,7 @@ function buildUnknownBoundaryCandidate(
id: candidateId,
recordingId: request.recordingId,
traceId: request.traceId,
+ transactionId: request.channelId,
target: { ...target },
direction: 'request',
request: {
@@ -812,6 +814,7 @@ function buildRequestGraphCandidate(
id: candidateId,
recordingId: request.recordingId,
traceId: request.traceId,
+ transactionId: request.channelId,
target: { ...target },
direction: 'request',
request: {
@@ -871,6 +874,7 @@ function buildRequestGraphCandidate(
interface LinkedResponseSource {
event: BrowserRecordingEvent;
links: BrowserRecordingLink[];
+ inputChains: BrowserRecordingLink[][];
stateLinks: BrowserRecordingLink[];
stateEvents: BrowserRecordingEvent[];
}
@@ -896,12 +900,23 @@ function linkedResponseSources(
const chain = [...current.links, link];
if (isReverseCryptoEvent(consumer)) {
const previous = output.get(consumer.id);
- if (!previous || chain.length < previous.links.length) {
+ if (!previous) {
output.set(consumer.id, {
event: consumer,
links: chain,
+ inputChains: [chain],
...stateSequence(consumer, eventsById, incoming),
});
+ } else {
+ const terminal = chain.at(-1)!;
+ if (!previous.inputChains.some((item) => item.at(-1)?.toPath === terminal.toPath)) {
+ previous.inputChains.push(chain);
+ }
+ const previousTarget = previous.links.at(-1)?.toPath;
+ if ((terminal.toPath === '$input' && previousTarget !== '$input')
+ || (terminal.toPath === previousTarget && chain.length < previous.links.length)) {
+ previous.links = chain;
+ }
}
}
const depth = current.depth + 1;
@@ -920,12 +935,24 @@ function buildResponseCandidate(
response: BrowserRecordingEvent,
source: LinkedResponseSource,
): BrowserProfileInferenceCandidate {
- const firstLink = source.links[0];
+ const dataChain = source.inputChains.find((chain) => chain.at(-1)?.toPath === '$input');
+ const primaryLinks = dataChain || source.links;
+ const firstLink = dataChain?.[0];
const { destination: inputPath, serialization } = requestMapping(firstLink?.fromPath);
const bodyFormat = responseBodyFormat(response, [serialization]);
- const exact = source.links.length > 0 && source.links.every((link) => link.confidence === 'exact');
+ const responseLinks = [...new Map(source.inputChains.flat().map((link) => [link.id, link])).values()];
+ const dynamicInputChains = source.inputChains.filter((chain) => chain.at(-1)?.toPath !== '$input');
+ const dynamicInputs = dynamicInputChains
+ .map((chain) => chain.at(-1)?.toPath)
+ .filter((path): path is string => Boolean(path && path !== '$input'));
+ const replayInputPaths = ['$input', ...dynamicInputs];
+ const supportsDynamicInputs = source.event.crypto?.adapterId === 'cryptojs'
+ && source.event.crypto.operation.toLowerCase().includes('decrypt')
+ && dynamicInputs.every((path) => path === '$input.key' || path === '$input.iv');
+ const exact = responseLinks.length > 0 && responseLinks.every((link) => link.confidence === 'exact');
const hasCallable = Boolean(source.event.callHandleId && source.event.callableCapable);
- const replayReady = exact && source.links.length === 1 && Boolean(inputPath) && hasCallable;
+ const replayReady = exact && dataChain?.length === 1
+ && (dynamicInputs.length === 0 || supportsDynamicInputs) && Boolean(inputPath) && hasCallable;
const argumentRoles = source.event.arguments || [];
const responseName = requestLabel(response);
const sourceName = sourceLabel(source.event);
@@ -938,7 +965,7 @@ function buildResponseCandidate(
eventIds: [response.id],
fromPath: firstLink?.fromPath,
}];
- source.links.forEach((link, index) => evidence.push({
+ responseLinks.forEach((link, index) => evidence.push({
id: `evidence-response-link-${link.id || `${response.id}-${source.event.id}-${index}`}`,
kind: link.confidence === 'exact' ? 'exact-value' : 'message-boundary',
strength: link.confidence === 'exact' ? 'proven' : 'supported',
@@ -974,6 +1001,14 @@ function buildResponseCandidate(
label: '页面仍保留本次解密调用的原函数、receiver 与固定参数模板',
eventIds: [source.event.id],
});
+ const responseMappings = (dataChain ? [dataChain, ...dynamicInputChains] : source.inputChains).map((chain) => {
+ const mapping = requestMapping(chain[0]?.fromPath);
+ return {
+ sourceEventId: source.event.id,
+ destination: mapping.destination,
+ serialization: mapping.serialization,
+ };
+ });
let score = 20;
if (exact) score += 40;
@@ -989,8 +1024,10 @@ function buildResponseCandidate(
} else {
missing.push({
kind: 'business-callable',
- label: exact && inputPath
- ? '已定位响应解密链;还需捕获上层业务函数,才能保留解码、解压与多阶段解密关系'
+ label: dynamicInputs.length && !supportsDynamicInputs
+ ? `响应解密还依赖每次响应中的 ${dynamicInputs.map((path) => path.replace(/^\$input\.?/, '')).join('、')};需捕获上层业务函数以保留动态参数关系`
+ : exact && inputPath
+ ? '已定位响应解密链;还需捕获上层业务函数,才能保留解码、解压与多阶段解密关系'
: '响应字段与页面解密调用尚未形成可回放的直接值链,请继续捕获当前解密现场',
action: 'capture-business-function',
});
@@ -1000,6 +1037,7 @@ function buildResponseCandidate(
id: candidateId,
recordingId: response.recordingId,
traceId: response.traceId,
+ transactionId: response.channelId,
target: { ...target },
direction: 'response',
request: {
@@ -1009,7 +1047,7 @@ function buildResponseCandidate(
bodyFormat,
destination: inputPath,
serialization,
- mappings: [{ sourceEventId: source.event.id, destination: inputPath, serialization }],
+ mappings: responseMappings,
},
source: {
eventId: source.event.id,
@@ -1017,6 +1055,7 @@ function buildResponseCandidate(
operation: source.event.operation,
crypto: source.event.crypto,
callHandleId: source.event.callHandleId,
+ dynamicInputPaths: replayReady ? replayInputPaths : undefined,
arguments: argumentRoles,
destination: inputPath,
serialization,
@@ -1027,6 +1066,7 @@ function buildResponseCandidate(
operation: source.event.operation,
crypto: source.event.crypto,
callHandleId: source.event.callHandleId,
+ dynamicInputPaths: replayReady ? replayInputPaths : undefined,
arguments: argumentRoles,
destination: inputPath,
serialization,
@@ -1038,7 +1078,7 @@ function buildResponseCandidate(
: `已定位 ${responseName} 到 ${sourceName} 的响应解密链`,
flow: [
inputPath ? `${responseName} · ${inputPath}` : responseName,
- ...(source.links.length > 1 ? [`${source.links.length - 1} 个响应准备步骤`] : []),
+ ...(primaryLinks.length > 1 ? [`${primaryLinks.length - 1} 个响应准备步骤`] : []),
sourceName,
'明文响应',
],
diff --git a/src/features/browser-recording/RecordingWorkspace.tsx b/src/features/browser-recording/RecordingWorkspace.tsx
index 742e4e7..9a1e71b 100644
--- a/src/features/browser-recording/RecordingWorkspace.tsx
+++ b/src/features/browser-recording/RecordingWorkspace.tsx
@@ -19,7 +19,12 @@ import {
BrowserTransformWorkspace,
type BrowserTransformSuggestionSeed,
} from '@/features/browser-transform/BrowserTransformWorkspace';
-import { createBrowserTransformProfileInput } from '@/features/browser-transform/profile-draft';
+import {
+ createBrowserTransformProfileInput,
+ pairedBrowserTransformCandidate,
+ type BrowserTransformProfileBinding,
+} from '@/features/browser-transform/profile-draft';
+import { browserGatewayNextStep, recordingEventDirection } from './presentation';
type RunTask = (task: () => Promise, success?: string) => Promise;
const DEEP_CAPTURE_AVAILABLE = !import.meta.env.FIREFOX;
@@ -30,7 +35,6 @@ interface RecordingWorkspaceProps {
run: RunTask;
gatewayShared: boolean;
onShareGateway: () => Promise;
- initialMode?: 'gateway' | 'recording' | 'deep';
}
const KIND_LABELS: Record = {
@@ -65,6 +69,14 @@ function confidenceLabel(candidate: BrowserProfileInferenceCandidate): string {
return `${level}置信度 · ${candidate.confidence.score}`;
}
+function candidateStatusLabel(candidate?: BrowserProfileInferenceCandidate): string {
+ if (!candidate) return '未检测到';
+ if (candidate.status === 'ready') return '可直接生成';
+ if (candidate.status === 'capture-required') return '需要完整捕获';
+ if (candidate.status === 'mapping-required') return '需要确认映射';
+ return '证据不足';
+}
+
function eventIcon(kind: BrowserRecordingEvent['kind']) {
if (kind === 'navigation') return ;
if (kind === 'interaction') return ;
@@ -183,9 +195,8 @@ export function RecordingWorkspace({
run,
gatewayShared,
onShareGateway,
- initialMode = 'recording',
}: RecordingWorkspaceProps) {
- const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>(initialMode);
+ const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>('recording');
const [autoArmRequest, setAutoArmRequest] = useState(0);
const [autoRecoveryRequest, setAutoRecoveryRequest] = useState(0);
const [recoveryProfileId, setRecoveryProfileId] = useState('');
@@ -202,6 +213,9 @@ export function RecordingWorkspace({
const [callableArguments, setCallableArguments] = useState('[]');
const [callableResult, setCallableResult] = useState();
const [gatewaySuggestion, setGatewaySuggestion] = useState();
+ const [pendingGatewayBinding, setPendingGatewayBinding] = useState();
+ const [captureCandidate, setCaptureCandidate] = useState();
+ useEffect(() => { setCaptureCandidate(undefined); }, [tab?.id]);
const load = useCallback(async () => {
const tabId = tab?.id;
@@ -284,6 +298,8 @@ export function RecordingWorkspace({
setSelectedTraceId('');
setSelectedEventId('');
setCallableResult(undefined);
+ setPendingGatewayBinding(undefined);
+ setCaptureCandidate(undefined);
}, captureValues ? '录制已开始;短时样本仅保留在本次浏览器会话,页面跳转后会自动接续' : '录制已开始,将跨页面记录业务执行链');
const stop = () => run(async () => {
@@ -295,6 +311,8 @@ export function RecordingWorkspace({
if (!recordingTarget) return;
setSnapshot(await request('recording.clear', recordingTarget));
setCallableResult(undefined);
+ setPendingGatewayBinding(undefined);
+ setCaptureCandidate(undefined);
}, '录制与录制型页面函数已清空');
const createCallable = () => run(async () => {
@@ -357,9 +375,22 @@ export function RecordingWorkspace({
const outgoingLinks = selectedEvent ? snapshot?.links.filter((link) => link.fromEventId === selectedEvent.id) || [] : [];
const incomingLinks = selectedEvent ? snapshot?.links.filter((link) => link.toEventId === selectedEvent.id) || [] : [];
const traceCandidates = snapshot?.profileCandidates.filter((candidate) => candidate.traceId === selectedTraceId) || [];
- const selectedCandidate = traceCandidates.find((candidate) => (
- candidate.sources.some((source) => source.eventId === selectedEventId) || candidate.request.eventId === selectedEventId
- )) || traceCandidates[0];
+ const sourceCandidates = traceCandidates.filter((candidate) => candidate.source.eventId === selectedEventId);
+ const selectedCandidate = sourceCandidates.length === 1 ? sourceCandidates[0] : undefined;
+ const pairedCandidate = selectedCandidate
+ ? pairedBrowserTransformCandidate(snapshot?.profileCandidates || [], selectedCandidate)
+ : undefined;
+ const boundaryCandidates = selectedCandidate ? [] : traceCandidates.filter((candidate) => candidate.request.eventId === selectedEventId);
+ const relatedCandidate = boundaryCandidates.length === 1 ? boundaryCandidates[0] : undefined;
+ const relatedSourceEvent = relatedCandidate
+ ? snapshot?.events.find((event) => event.id === relatedCandidate.source.eventId)
+ : undefined;
+ const selectedEventDirection = selectedEvent
+ ? recordingEventDirection(selectedEvent, traceCandidates)
+ : undefined;
+ const gatewayNextStep = selectedCandidate
+ ? browserGatewayNextStep(selectedCandidate, pairedCandidate)
+ : undefined;
const candidateSourceEvent = selectedCandidate
? snapshot?.events.find((event) => event.id === selectedCandidate.source.eventId)
: undefined;
@@ -382,7 +413,9 @@ export function RecordingWorkspace({
};
const continueInference = (candidate: BrowserProfileInferenceCandidate) => {
+ setCaptureCandidate(candidate);
setRecoveryProfileId('');
+ setSelectedTraceId(candidate.traceId);
setSelectedEventId(candidate.capturePlan?.matcherEventId
|| (candidate.sources.length > 1 ? candidate.request.eventId : candidate.source.eventId));
setAutoArmRequest((current) => current + 1);
@@ -390,6 +423,7 @@ export function RecordingWorkspace({
};
const openRecovery = (profileId: string) => {
+ setCaptureCandidate(undefined);
setRecoveryProfileId(profileId);
setAutoRecoveryRequest((current) => current + 1);
setWorkspaceMode('deep');
@@ -407,6 +441,37 @@ export function RecordingWorkspace({
capturedSample?: CapturedCallableSample,
) => {
if (!tab) throw new Error('目标标签页已经关闭');
+ const binding: BrowserTransformProfileBinding = { candidate, callable };
+ const pair = pairedBrowserTransformCandidate(snapshot?.profileCandidates || [], candidate, true);
+ let pairedBinding = pendingGatewayBinding?.candidate.id === pair?.id ? pendingGatewayBinding : undefined;
+ if (!pairedBinding && pair) {
+ if (pair.status === 'capture-required') {
+ setPendingGatewayBinding(binding);
+ continueInference(pair);
+ return;
+ }
+ if (pair.status !== 'ready') {
+ throw new Error(`已检测到${pair.direction === 'request' ? '请求' : '响应'}方向,但${candidateStatusLabel(pair)},不能静默保存为单向网关`);
+ }
+ const pairEvent = snapshot?.events.find((item) => item.id === pair.source.eventId);
+ if (!eventAvailableInDocument(pairEvent, currentDocumentId, documentAvailable) || !snapshot?.status.target) {
+ throw new Error('配对方向属于另一个页面文档,请返回对应页面现场后再生成');
+ }
+ const pairInputCount = pair.source.dynamicInputPaths?.length || 1;
+ let pairCallable = snapshot.callables.find((item) => item.provenance.eventId === pair.source.eventId
+ && item.inputSlots.filter((slot) => !slot.retained).length === pairInputCount);
+ if (!pairCallable) {
+ if (!pair.source.callHandleId) throw new Error('配对方向没有可复用的页面调用句柄');
+ pairCallable = await request('callable.create', {
+ ...snapshot.status.target,
+ source: 'recording',
+ callHandleId: pair.source.callHandleId,
+ name: `${pair.source.crypto?.algorithm || pair.source.crypto?.operation || pair.source.operation} 页面函数`,
+ dynamicInputPaths: pair.source.dynamicInputPaths,
+ });
+ }
+ pairedBinding = { candidate: pair, callable: pairCallable };
+ }
const sourceEvent = snapshot?.events.find((item) => item.id === candidate.source.eventId);
const boundaryEvent = snapshot?.events.find((item) => item.id === candidate.request.eventId);
const profile = await request('transform.profile.save', createBrowserTransformProfileInput(
@@ -414,15 +479,21 @@ export function RecordingWorkspace({
sourceEvent,
callable,
candidate,
+ undefined,
+ pairedBinding,
));
+ const callables = [callable, pairedBinding?.callable].filter((item): item is BrowserPageCallable => Boolean(item));
+ const callableIds = new Set(callables.map((item) => item.id));
setSnapshot((current) => current ? {
...current,
- callables: [...current.callables.filter((item) => item.id !== callable.id), callable],
+ callables: [...current.callables.filter((item) => !callableIds.has(item.id)), ...callables],
} : current);
+ setPendingGatewayBinding(undefined);
+ setCaptureCandidate(undefined);
setGatewaySuggestion((current) => ({
revision: (current?.revision || 0) + 1,
candidate,
- callable,
+ callables,
profile,
sampleBody: capturedSample?.body || shortSample(candidate.direction === 'response' ? boundaryEvent : sourceEvent),
sampleLabel: capturedSample?.label || (candidate.direction === 'response' && boundaryEvent
@@ -447,17 +518,20 @@ export function RecordingWorkspace({
if (!currentSnapshot) throw new Error('没有可用的录制现场');
const target = currentSnapshot.status.target;
if (!target) throw new Error('录制文档已经失效');
- let callable = currentSnapshot.callables.find((item) => item.provenance.eventId === candidate.source.eventId);
+ const inputCount = candidate.source.dynamicInputPaths?.length || 1;
+ let callable = currentSnapshot.callables.find((item) => item.provenance.eventId === candidate.source.eventId
+ && item.inputSlots.filter((slot) => !slot.retained).length === inputCount);
if (!callable) {
callable = await request('callable.create', {
...target,
source: 'recording',
callHandleId: candidate.source.callHandleId,
name: `${candidate.source.crypto?.algorithm || candidate.source.crypto?.operation || candidate.source.operation} 页面函数`,
+ dynamicInputPaths: candidate.source.dynamicInputPaths,
});
}
await openSuggestedGateway(candidate, callable);
- }, '已根据录制证据生成并保存明文网关');
+ }, '当前方向已完成;存在配对方向时将继续捕获并合并为一个网关');
return
@@ -521,11 +595,12 @@ export function RecordingWorkspace({
{!traceEvents.length ?
当前 Trace 没有事件
: traceEvents.map((event, index) => {
const linked = snapshot?.links.some((link) => link.fromEventId === event.id || link.toEventId === event.id);
const callableAvailable = eventAvailableInDocument(event, currentDocumentId, documentAvailable);
+ const flowDirection = recordingEventDirection(event, traceCandidates);
return
{String(index + 1).padStart(2, '0')}{index < traceEvents.length - 1 ? : null}
;
@@ -535,7 +610,7 @@ export function RecordingWorkspace({