Compare commits

...
6 Commits
Author SHA1 Message Date
go0p d35912c7cf feat: support tab-wide recording and release notes
CI / Test and build (push) Waiting to run
2026-09-24 14:50:33 +08:00
go0p 8c84e16012 chore(release): bump version to 0.2.6
CI / Test and build (push) Canceled after 0s
2026-09-17 17:55:17 +08:00
go0p 601f8acbcf fix(capture): automate safe bidirectional gateways for users and agents 2026-09-17 17:24:54 +08:00
go0p 45f53604e8 chore(release): bump version to 0.2.5
CI / Test and build (push) Canceled after 0s
2026-09-16 15:51:56 +08:00
go0p 4b0df8c706 feat(browser): expose proxy state and browser identity (#9)
* feat(proxy): reflect and release browser proxy control

* feat(browser): report browser product identity
2026-09-16 15:51:33 +08:00
go0p 2000d59054 fix(browser): improve transform mapping and browser analysis capabilities
CI / Test and build (push) Canceled after 0s
2026-09-14 11:42:20 +08:00
84 changed files with 3838 additions and 640 deletions
+1 -1
View File
@@ -126,7 +126,7 @@ The independent `page-recorder-main-world.js` entrypoint temporarily wraps user-
Each click or submit begins a five-second business Trace. Nested and subsequent events share that Trace. Inputs and outputs are reduced to bounded evidence paths, byte lengths, encodings, and a randomly seeded 64-bit correlation fingerprint. The seed remains inside one page document and is regenerated for every document observer, so fingerprints cannot be compared across document boundaries. Matching an earlier output fingerprint to a later input fingerprint creates an exact Pipeline link. This is evidence of value equality inside one document segment, not proof of semantic causality.
Raw previews are disabled by default. Enabling them requires `browser.recording.sensitive.read` and caps each preview at 8 KiB. A user-started recording is a tab/frame-scoped Session: the current document keeps live hooks and handles, while the background merges bounded document segments into extension-only `storage.session`. A full navigation is recorded as a first-class Trace event; the previous segment is sealed, the destination document receives a new observer with the same Session identity and a synchronized global sequence, and recording continues until explicit stop, expiry, clear, or tab close. The single per-target Session is removed by a new recording, explicit clear, tab close, or browser-session end. Previews are never written to persistent storage or included in audit or AI request-analysis payloads. Recording is bounded to 500 aggregate events, 48 evidence items per side, 1,000 links, and 64 live callable handles per document.
Raw previews are disabled by default. Enabling them requires `browser.recording.sensitive.read` and caps each preview at 8 KiB. A user-started local recording covers every accessible frame in the selected tab and automatically attaches newly committed frames; each frame still owns an independent document Session, hook set, evidence salt, and callable registry, while the UI merges their bounded timelines. Agent-owned recordings remain explicitly frame-scoped. A full navigation is recorded as a first-class Trace event; the previous segment is sealed, the destination document receives a new observer with the same Session identity and a synchronized global sequence, and recording continues until explicit stop, expiry, clear, or tab close. Sessions are removed by a new recording, explicit clear, tab close, or browser-session end. Previews are never written to persistent storage or included in audit or AI request-analysis payloads. Recording is bounded to 500 aggregate events, 48 evidence items per side, 1,000 links, and 64 live callable handles per document.
Navigation is both a business event and a strict execution-context boundary. Full document navigation, reload, browser Back/Forward, same-document History changes and fragment changes are distinguished. If Back/Forward restores the original document from BFCache, its recorder, handles and callables are resumed without clearing earlier evidence; if the browser performs a hard reload, the historical evidence remains but the destroyed closure heap is truthfully unavailable. MAIN-world lifecycle and the tab-scoped Session are separate states, so a temporary document transition no longer appears as a completed recording. A grant-owned recording remains document-bound and stops at navigation instead of silently extending an Agent's authority into a new document.
+3 -1
View File
@@ -338,7 +338,9 @@ Chrome Store 构建声明 Chrome 138+。用户需要在扩展详情页开启“
https://aliyun-oss.yaklang.com/chrome-extension/manifest.json
```
manifest 的 `latest` 指向最新版本,`versions[0]` 为完整记录,最多保留 10 个历史版本。每个版本按 `variant``chrome-store` / `chrome-enterprise` / `firefox` / `firefox-amo`)匹配 artifact,字段包括 `url``filename``sha256``size``checksum_url`manifest 自身的 SHA-256 在同目录的 `manifest.json.sha256.txt`
manifest 的 `latest` 指向最新版本,`versions[0]` 为完整记录,最多保留 10 个历史版本。每个版本`notes` 是面向用户的真实更新说明,`variant``chrome-store` / `chrome-enterprise` / `firefox` / `firefox-amo`)匹配 artifact,字段包括 `url``filename``sha256``size``checksum_url`manifest 自身的 SHA-256 在同目录的 `manifest.json.sha256.txt`
每次修改 `package.json` 的版本时,必须同步在 `release-notes.json` 中增加该版本的更新说明;缺失或内容为空会让打包和发布直接失败。YTray 等消费方会原样展示这些说明。
推荐的消费流程:
+3 -1
View File
@@ -2,7 +2,7 @@
"name": "yakit-chrome-client",
"description": "Yakit Browser Extension",
"private": true,
"version": "0.2.4",
"version": "0.2.7",
"type": "module",
"packageManager": "[email protected]",
"scripts": {
@@ -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",
+11
View File
@@ -0,0 +1,11 @@
{
"0.2.7": [
"浏览器录制现在覆盖标签页内所有可访问页面,并自动接续后续加载的登录页面。",
"由 YTray 启动的托管浏览器会自动向 Yakit 发起配对连接。",
"修复代理绕过列表无法正常换行编辑的问题,并改善深色界面的选项显示。",
"插件更新页现在会展示随版本发布的真实更新内容。"
],
"0.2.6": [
"自动为用户和 Agent 建立安全的双向浏览器网关。"
]
}
+14 -2
View File
@@ -34,8 +34,13 @@ function artifactFingerprint(artifacts) {
}
function toVersionEntry(entry) {
if (!Array.isArray(entry.notes) || entry.notes.length === 0
|| entry.notes.some((note) => typeof note !== 'string' || note.trim() === '')) {
throw new Error(`release ${entry.version} must contain non-empty notes`);
}
return {
version: entry.version,
notes: entry.notes,
published_at: entry.built_at,
commit: entry.commit ?? null,
artifacts: entry.artifacts.map((a) => ({
@@ -65,6 +70,11 @@ function validate(manifest) {
if (!Array.isArray(versionEntry.artifacts) || versionEntry.artifacts.length === 0) {
throw new Error(`version ${versionEntry.version} has no artifacts`);
}
if (versionEntry.notes !== undefined
&& (!Array.isArray(versionEntry.notes) || versionEntry.notes.length === 0
|| versionEntry.notes.some((note) => typeof note !== 'string' || note.trim() === ''))) {
throw new Error(`version ${versionEntry.version} has invalid notes`);
}
const variants = new Set();
for (const artifact of versionEntry.artifacts) {
if (variants.has(artifact.variant)) throw new Error(`duplicate variant ${artifact.variant} in version ${versionEntry.version}`);
@@ -103,8 +113,10 @@ if (args['existing-manifest']) {
const newEntry = toVersionEntry(entry);
const idx = versions.findIndex((v) => v.version === entry.version);
if (idx >= 0 && artifactFingerprint(versions[idx].artifacts) === artifactFingerprint(entry.artifacts)) {
// Idempotent rerun: keep the original entry (published_at stays stable).
console.log(`version ${entry.version} already in manifest with identical artifacts; kept as-is`);
// An idempotent rerun may backfill release notes without changing immutable
// artifacts or their original publication metadata.
versions[idx] = { ...versions[idx], notes: newEntry.notes };
console.log(`version ${entry.version} already in manifest with identical artifacts; release notes synchronized`);
} else {
if (idx >= 0) {
versions.splice(idx, 1);
+6 -1
View File
@@ -65,6 +65,11 @@ const distDir = resolve(root, String(args.dist ?? 'dist'));
const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
const { version } = pkg;
const notesByVersion = JSON.parse(await readFile(resolve(root, 'release-notes.json'), 'utf8'));
const notes = notesByVersion[version]?.map((note) => String(note).trim()).filter(Boolean);
if (!Array.isArray(notes) || notes.length === 0) {
throw new Error(`release-notes.json must contain at least one note for version ${version}`);
}
let commit = null;
try {
@@ -138,6 +143,6 @@ for (const target of VARIANTS) {
console.log(`packaged ${filename} (${size} bytes, sha256 ${sha256.slice(0, 12)}…)`);
}
const entry = { version, commit, built_at: new Date().toISOString(), artifacts };
const entry = { version, notes, commit, built_at: new Date().toISOString(), artifacts };
await writeFile(resolve(distDir, 'release-entry.json'), `${JSON.stringify(entry, null, 2)}\n`);
console.log(`release entry written: ${resolve(distDir, 'release-entry.json').slice(root.length + 1)} (version ${version})`);
+113
View File
@@ -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)}`)
}
+142
View File
@@ -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('<form id="form"><input name="password" value="old"><button>Submit</button></form><script>globalThis.cachedFetch = fetch.bind(window); globalThis.cachedSubmit = HTMLFormElement.prototype.submit;</script>')
}
})
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))
}
+103
View File
@@ -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() }
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { chromium } from 'playwright-core';
import { resolveChromiumPath } from './resolve-chromium.mjs';
// Uses an isolated profile; never changes the user's browser or system proxy.
const profile = await mkdtemp(join(tmpdir(), 'yakit-proxy-control-'));
const extension = resolve('.output/chrome-mv3');
const context = await chromium.launchPersistentContext(profile, {
executablePath: await resolveChromiumPath(), headless: true,
viewport: { width: 390, height: 640 }, reducedMotion: 'reduce',
args: [`--disable-extensions-except=${extension}`, `--load-extension=${extension}`, '--proxy-server=http://127.0.0.1:18083'],
});
try {
const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');
const id = new URL(worker.url()).host;
const page = await context.newPage();
await page.goto(`chrome-extension://${id}/ytray-bootstrap.html?manager=ytray&instanceId=proxy-test&badge=A&startupProxy=${encodeURIComponent('http://127.0.0.1:18083')}&target=chrome://version`);
await page.waitForURL('chrome://version/');
await page.goto(`chrome-extension://${id}/options.html`);
const call = async (action, payload) => {
const response = await page.evaluate(({ action, payload }) => chrome.runtime.sendMessage({ action, payload }), { action, payload });
assert.equal(response.ok, true, response.error);
return response.data;
};
const launch = await call('proxy.status');
assert.equal((await call('state.get')).startupProxy, 'http://127.0.0.1:18083');
assert.equal(launch.followingStartup, true);
assert.equal(launch.control, 'controllable_by_this_extension');
assert.equal(launch.activeProfileId, undefined);
assert.match(launch.label, /18083/);
await call('proxy.switch', { id: 'direct' });
assert.equal((await call('proxy.status')).activeProfileId, 'direct');
await call('proxy.switch', { id: 'yakit-mitm' });
assert.equal((await call('proxy.status')).activeProfileId, 'yakit-mitm');
await call('proxy.auto.apply');
assert.equal((await call('proxy.status')).activeProfileId, 'auto');
await call('proxy.switch', { id: 'system' });
assert.equal((await call('proxy.status')).activeProfileId, 'system');
await call('proxy.release');
assert.deepEqual(await call('proxy.status'), launch);
await page.goto(`chrome-extension://${id}/popup.html`);
await page.getByRole('button', { name: '代理', exact: true }).click();
await page.getByRole('status').filter({ hasText: '实际代理' }).getByText('http://127.0.0.1:18083', { exact: true }).waitFor();
assert.equal(await page.getByRole('radio', { name: /直接连接/ }).getAttribute('aria-checked'), 'false');
const follow = page.getByRole('radio', { name: /跟随启动配置/ });
assert.equal(await follow.getAttribute('aria-checked'), 'true');
await page.evaluate(() => {
const startup = document.querySelector('.startup-proxy-option');
const ordinary = document.querySelector('.popup-proxy-list > button');
for (const [a, b] of [[startup.querySelector('.startup-proxy-icon'), ordinary.querySelector('.popup-mode-icon')], [startup.querySelector('strong'), ordinary.querySelector('strong')], [startup.querySelector('small'), ordinary.querySelector('small')]]) {
if (Math.abs(a.getBoundingClientRect().x - b.getBoundingClientRect().x) > 1) throw new Error('Proxy mode columns are not aligned');
}
});
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.getByRole('radio', { name: /直接连接/ }).click();
await page.locator('.popup-global-notice').waitFor();
await page.evaluate(() => {
const notice = document.querySelector('.popup-global-notice');
const animation = notice.getAnimations()[0];
if (!animation) throw new Error('Expected notice entrance animation');
animation.pause();
for (const time of [0, 40, 80, 159, 200]) {
animation.currentTime = time;
const rect = notice.getBoundingClientRect();
const parent = notice.offsetParent.getBoundingClientRect();
if (Math.abs(rect.x + rect.width / 2 - (parent.x + parent.width / 2)) > 1) {
throw new Error(`Notice is not centered at animation time ${time}ms`);
}
}
animation.finish();
});
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.waitForFunction(() => document.querySelector('[aria-label="实际代理状态"]')?.textContent === '实际代理直接连接');
assert.equal(await follow.getAttribute('aria-checked'), 'false');
await follow.click();
await page.waitForFunction(() => document.querySelector('.startup-proxy-option [role="radio"]')?.getAttribute('aria-checked') === 'true');
assert.deepEqual(await call('proxy.status'), launch);
await page.getByRole('button', { name: '解释跟随启动配置' }).focus();
await page.getByRole('tooltip').waitFor();
assert.match(await page.getByRole('tooltip').innerText(), /切换后使用浏览器启动时的网络配置/);
assert.doesNotMatch(await page.getByRole('tooltip').innerText(), /清除|接管/);
await page.getByRole('radio', { name: /跟随启动配置/ }).focus();
await page.mouse.move(4, 4);
await mkdir('.artifacts/proxy', { recursive: true });
await page.screenshot({ path: '.artifacts/proxy/launch-proxy.png' });
// External settings changes must update an already-open view, without storage mutations.
await page.evaluate(() => chrome.proxy.settings.set({ scope: 'regular', value: { mode: 'direct' } }));
await page.getByRole('status').filter({ hasText: '实际代理' }).getByText('直接连接', { exact: true }).waitFor();
assert.equal(await page.getByRole('radio', { name: /直接连接/ }).getAttribute('aria-checked'), 'false');
await page.goto(`chrome-extension://${id}/ytray-bootstrap.html?manager=ytray&instanceId=direct-test&badge=A&startupProxy=direct&target=chrome://version`);
await page.waitForURL('chrome://version/');
await page.goto(`chrome-extension://${id}/popup.html`);
await page.getByRole('button', { name: '代理', exact: true }).click();
assert.equal(await page.getByRole('radio', { name: /跟随启动配置/ }).count(), 0);
console.log('PASS: launch proxy → direct → fixed → PAC → system → release; live status; stale selection not marked active.');
} finally {
await context.close();
await rm(profile, { recursive: true, force: true });
}
+2
View File
@@ -89,6 +89,8 @@ const manifest = JSON.parse(manifestBytes.toString('utf8'));
assert(manifest.latest === entry.version, `manifest.latest ${manifest.latest} != ${entry.version}`);
const versionEntry = manifest.versions.find((v) => v.version === entry.version);
assert(versionEntry, `manifest has no entry for version ${entry.version}`);
assert(JSON.stringify(versionEntry.notes) === JSON.stringify(entry.notes),
`manifest release notes do not match release entry for ${entry.version}`);
assert(versionEntry.artifacts.length === entry.artifacts.length,
`manifest artifacts count ${versionEntry.artifacts.length} != ${entry.artifacts.length}`);
for (const artifact of entry.artifacts) {
+4
View File
@@ -18,6 +18,8 @@ import {
saveProxyRuleSource,
setProxyAuthPassword,
switchProxy,
getProxyStatus,
releaseProxy,
} from '@/features/proxy/service';
import { updateState } from '@/platform/storage/state';
@@ -26,6 +28,8 @@ export const handleProxyRequest: BackgroundRequestHandler = async (request) => {
case 'proxy.save': return ok(await saveProxyProfile(request.payload));
case 'proxy.delete': return ok(await removeProxyProfile(request.payload.id));
case 'proxy.switch': return ok(await switchProxy(request.payload.id));
case 'proxy.status': return ok(await getProxyStatus());
case 'proxy.release': return ok(await releaseProxy());
case 'proxy.rule.save': {
const rule = request.payload;
return ok(await updateState((state) => {
+23 -7
View File
@@ -3,11 +3,16 @@ import { ok } from '../response';
import { requiredDebuggerTarget, requiredRequestTarget } from '../request-context';
import {
browserRecordingStatus,
clearTabBrowserRecording,
clearBrowserRecording,
createRecordedPageCallable,
getBrowserRecording,
getTabBrowserRecording,
startTabBrowserRecording,
startBrowserRecording,
stopTabBrowserRecording,
stopBrowserRecording,
tabBrowserRecordingStatus,
} from '@/features/browser-recording/service';
import {
createCapturedPageCallable,
@@ -35,7 +40,9 @@ export const handleRecordingRequest: BackgroundRequestHandler = async (request,
case 'recording.start': {
const input = request.payload;
const target = await requiredRequestTarget(input, sender);
const snapshot = await startBrowserRecording(target, input);
const snapshot = input.scope === 'tab'
? await startTabBrowserRecording(target.tabId, input)
: await startBrowserRecording(target, input);
void appendAuditEvent({
category: 'capability',
action: 'recording.start',
@@ -45,18 +52,25 @@ export const handleRecordingRequest: BackgroundRequestHandler = async (request,
});
return ok(snapshot);
}
case 'recording.status': return ok(await browserRecordingStatus(
await requiredRequestTarget(request.payload, sender),
));
case 'recording.status': {
const target = await requiredRequestTarget(request.payload, sender);
return ok(request.payload.scope === 'tab'
? await tabBrowserRecordingStatus(target.tabId)
: await browserRecordingStatus(target));
}
case 'recording.get': {
const target = await requiredRequestTarget(request.payload, sender);
const snapshot = await getBrowserRecording(target, request.payload.limit, true);
const snapshot = request.payload.scope === 'tab'
? await getTabBrowserRecording(target.tabId, request.payload.limit, true)
: await getBrowserRecording(target, request.payload.limit, true);
await stageBrowserProfileEvidence(snapshot);
return ok(snapshot);
}
case 'recording.clear': {
const target = await requiredRequestTarget(request.payload, sender);
const snapshot = await clearBrowserRecording(target, true);
const snapshot = request.payload.scope === 'tab'
? await clearTabBrowserRecording(target.tabId, true)
: await clearBrowserRecording(target, true);
void appendAuditEvent({
category: 'capability',
action: 'recording.clear',
@@ -67,7 +81,9 @@ export const handleRecordingRequest: BackgroundRequestHandler = async (request,
}
case 'recording.stop': {
const target = await requiredRequestTarget(request.payload, sender);
const snapshot = await stopBrowserRecording(target, true);
const snapshot = request.payload.scope === 'tab'
? await stopTabBrowserRecording(target.tabId, true)
: await stopBrowserRecording(target, true);
await stageBrowserProfileEvidence(snapshot);
void appendAuditEvent({
category: 'capability',
+16 -3
View File
@@ -428,13 +428,25 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
}
const state = await updateState((current) => ({
...current,
bridge: { ...current.bridge, managedInstance: request.payload },
startupProxy: request.payload.startupProxy,
bridge: {
...current.bridge,
browserName: request.payload.browserName,
browserVersion: request.payload.browserVersion,
managedInstance: {
manager: request.payload.manager, instanceId: request.payload.instanceId, badge: request.payload.badge,
},
},
}));
await syncManagedInstanceBadge(state.bridge.managedInstance);
if (state.bridge.autoConnect && state.bridge.pairedEngine) {
engineBridge.disconnect();
await stopPairedBrowserTasks();
await engineBridge.connect(state.bridge);
void stopPairedBrowserTasks()
.then(() => engineBridge.connect(state.bridge))
.catch((error) => console.error('Managed browser reconnect failed', error));
} else if (!state.bridge.pairedEngine) {
void engineBridge.startPairing()
.catch((error) => console.error('Managed browser pairing failed', error));
}
return ok(engineBridge.getStatus());
}
@@ -512,6 +524,7 @@ export function runBackground(): void {
) => {
if ([
'bridge.status.changed',
'proxy.status.changed',
'bridge.pairing.status.changed',
'network.capture.changed',
'deep.capture.changed',
+2
View File
@@ -63,6 +63,7 @@ async function send<T>(action: string, payload?: unknown): Promise<T> {
export default defineContentScript({
matches: ['http://*/*', 'https://*/*'],
allFrames: true,
runAt: 'document_start',
async main(ctx) {
@@ -77,6 +78,7 @@ export default defineContentScript({
console.warn('[Yakit Browser Agent] MAIN-world bridge is unavailable; continuing without page Eval/Invoke.', error);
});
}
if (window.top !== window) return;
const host = document.createElement('yakit-browser-agent');
const shadow = host.attachShadow({ mode: 'open' });
+8
View File
@@ -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); }
+2 -2
View File
@@ -16,6 +16,7 @@ import {
import { cookieKey, cookieRemovalInput } from '@/features/cookies/presentation';
import { AutoSwitchView } from '@/features/proxy/ui/AutoSwitchView';
import { ProxyProfilesView } from '@/features/proxy/ui/ProxyProfilesView';
import { useProxyStatus } from '@/features/proxy/ui/ProxyStatusBar';
import { RuleSourcesView } from '@/features/proxy/ui/RuleSourcesView';
import { RecordingWorkspace } from '@/features/browser-recording/RecordingWorkspace';
import { AuthorizationTestingWorkspace } from '@/features/authorization-testing/ui/AuthorizationTestingWorkspace';
@@ -337,7 +338,7 @@ function ActivityLog({ run, busy }: { run: (task: () => Promise<void>, success?:
}
function Overview({ state, bridge, tab, navigate, run, busy }: { state: ExtensionState; bridge: BridgeStatus; tab?: ActiveTabInfo; navigate: (value: Section) => void; run: (task: () => Promise<void>, success?: string) => Promise<void>; busy: boolean }) {
const activeProxy = state.proxyProfiles.find((profile) => profile.id === state.activeProxyId)?.name || (state.activeProxyId === 'auto' ? '自动切换' : '未知');
const activeProxy = useProxyStatus(state).label;
const [runtime, setRuntime] = useState<AgentRuntime>({ state: 'idle', updatedAt: Date.now(), actions: [] });
const [network, setNetwork] = useState<NetworkCaptureStatus>();
const [loginContext, setLoginContext] = useState<PageContext>();
@@ -681,7 +682,6 @@ function GatewayWorkspace({
run={run}
gatewayShared={bridge.state === 'connected'}
onShareGateway={shareTransform}
initialMode="gateway"
/>
</div>;
}
+53 -20
View File
@@ -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,
@@ -48,6 +48,7 @@ import {
type RecordingTraceContext,
type RecordingTraceRuntime,
} from '@/features/browser-recording/main-world/trace';
import { recordingExpiryDelay } from '@/features/browser-recording/expiry';
import { RetainedCallBudget } from '@/features/browser-recording/main-world/retained-call-budget';
import { estimateRetainedCallBytes } from '@/features/browser-recording/main-world/retained-value-size';
import { ExtensionError } from '@/shared/errors';
@@ -199,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;
@@ -208,7 +214,6 @@ interface RecordedCallHandle {
outputDataType?: string;
outputEncoding?: PageCallableMetadata['output']['encoding'];
resultMode: 'sync' | 'promise';
adaptInput(value: unknown): unknown;
}
interface RecorderController {
@@ -484,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}`,
@@ -491,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',
@@ -673,7 +685,14 @@ export default defineUnlistedScript(() => {
if (active || !startedAt) return;
active = true;
installObservers();
if (options.expiresAt) expiryTimer = window.setTimeout(stop, Math.max(0, options.expiresAt - Date.now()));
scheduleExpiry();
}
function scheduleExpiry(): void {
const delay = recordingExpiryDelay(options.expiresAt);
if (delay === undefined) return;
if (delay === 0) { stop(); return; }
expiryTimer = window.setTimeout(stop, delay);
}
function snapshot(limit = options.maxEntries): RecorderSnapshot {
@@ -720,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,
@@ -732,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',
@@ -759,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);
},
});
@@ -778,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)
@@ -863,7 +892,7 @@ export default defineUnlistedScript(() => {
reseedFingerprints();
active = true;
installObservers();
if (options.expiresAt) expiryTimer = window.setTimeout(stop, Math.max(0, options.expiresAt - Date.now()));
scheduleExpiry();
return snapshot();
}
if (command === 'resume') {
@@ -933,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') {
+3 -1
View File
@@ -206,7 +206,9 @@
.popup-footer { margin-top: auto; padding: 10px 14px 12px; border-top: 1px solid var(--border); background: var(--surface); }
.popup-capture { width: 100%; height: 36px; border-radius: 7px; font-size: var(--text-md); box-shadow: 0 1px 0 color-mix(in srgb, var(--primary-strong) 55%, transparent); }
.popup-notice { display: block; margin-top: 6px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 16px; text-align: center; white-space: nowrap; text-overflow: ellipsis; }
.popup-global-notice { position: absolute; z-index: 20; left: 50%; bottom: 54px; max-width: calc(100% - 28px); padding: 7px 11px; overflow: hidden; border: 1px solid var(--border); border-radius: 999px; background: var(--foreground); color: var(--surface); box-shadow: var(--shadow-md); font-size: var(--text-xs); line-height: 16px; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; transform: translateX(-50%); animation: popup-content-in .16s ease-out; }
/* Keep horizontal centering independent of the entrance animation's transform. */
.popup-global-notice { position: absolute; z-index: 20; left: 50%; bottom: 54px; max-width: calc(100% - 28px); padding: 7px 11px; overflow: hidden; border: 1px solid var(--border); border-radius: 999px; background: var(--foreground); color: var(--surface); box-shadow: var(--shadow-md); font-size: var(--text-xs); line-height: 16px; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; translate: -50% 0; animation: popup-content-in .16s ease-out; }
@media (prefers-reduced-motion: reduce) { .popup-global-notice { animation: none; } }
.popup-loading { width: 390px; height: 230px; display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--muted); background: var(--background); font-size: var(--text-md); }
.spin { animation: spin .8s linear infinite; }
@@ -2,6 +2,7 @@ import { Braces, ChevronRight, Cookie, Network, Radio, ShieldCheck, UserRoundCog
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { request } from '@/platform/messaging/runtime';
import { useProxyStatus } from '@/features/proxy/ui/ProxyStatusBar';
import { READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
import type { ActiveTabInfo, ExtensionState, UserAgentResolution } from '@/types/models';
@@ -25,9 +26,7 @@ interface OverviewQuickViewProps {
export function OverviewQuickView({
state, tab, grantActive, busy, run, setState, cookieCount, uaResolution, onNavigate, onOpenContext, onCapture,
}: OverviewQuickViewProps) {
const activeProxy = state.activeProxyId === 'auto'
? '自动切换'
: state.proxyProfiles.find((profile) => profile.id === state.activeProxyId)?.name || '未选择';
const activeProxy = useProxyStatus(state).label;
const targetAvailable = Boolean(tab?.url?.startsWith('http'));
return <section className="popup-overview-view">
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { AlertCircle, Check, ExternalLink, Globe2, LoaderCircle, Network, Route } from 'lucide-react';
import { request } from '@/platform/messaging/runtime';
import type { ActiveTabInfo, ExtensionState, ProxyProfile, ProxyRulePreview } from '@/types/models';
import { ProxyStatusBar, StartupProxyOption, useProxyStatus } from '@/features/proxy/ui/ProxyStatusBar';
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
@@ -43,10 +44,11 @@ function routeKindLabel(preview?: ProxyRulePreview): string {
}
export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }: ProxyQuickViewProps) {
const status = useProxyStatus(state);
const [preview, setPreview] = useState<ProxyRulePreview>();
const currentHostname = hostname(tab?.url);
const autoActive = state.activeProxyId === 'auto';
const activeProfile = state.proxyProfiles.find((profile) => profile.id === state.activeProxyId);
const autoActive = status.activeProfileId === 'auto';
const activeProfile = state.proxyProfiles.find((profile) => profile.id === status.activeProfileId);
const routableProfiles = useMemo(
() => state.proxyProfiles.filter((profile) => profile.kind === 'direct' || profile.kind === 'fixed_servers'),
[state.proxyProfiles],
@@ -130,7 +132,7 @@ export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }:
};
const effectiveProfile = state.proxyProfiles.find((profile) => profile.id === preview?.effectiveProfileId);
const activeModeName = autoActive ? '自动切换' : activeProfile?.name || '未选择';
const activeModeName = autoActive ? '自动切换' : status.label;
const siteHint = !autoActive
? `当前使用“${activeModeName}”;选择网站出口后将启用自动切换。`
: siteTarget === AUTOMATIC_TARGET
@@ -142,13 +144,14 @@ export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }:
const routeKindText = autoActive ? routeKindLabel(preview) : '全局模式';
return <section className="popup-view popup-tool-view popup-proxy-view">
<ProxyStatusBar status={status} />
{currentHostname ? <section className="popup-site-router" aria-label="当前站点路由">
<div className="popup-site-router__heading">
<div><Globe2 size={16} /><span><small></small><strong title={currentHostname}>{currentHostname}</strong></span></div>
<i className={routeKind}>{routeKindText}</i>
</div>
<div className="popup-site-decision" title={autoActive ? preview?.matchedCondition : activeModeName}>
<span>{routeLabel}</span><i></i><strong>{routeProfile?.name || '—'}</strong>
<span>{routeLabel}</span><i></i><strong>{routeProfile?.name || status.label}</strong>
</div>
<div className="popup-site-picker">
<label htmlFor="popup-site-proxy"> <span></span></label>
@@ -168,13 +171,14 @@ export function ProxyQuickView({ state, setState, busy, run, tab, onOpenFull }:
<div className="popup-mode-heading"><span><strong></strong><small></small></span><i>{activeModeName}</i></div>
<div className="popup-proxy-list popup-proxy-list--view" role="radiogroup" aria-label="浏览器代理模式">
<StartupProxyOption state={state} status={status} setState={setState} run={run} busy={busy} />
<button role="radio" aria-checked={autoActive} className={autoActive ? 'is-active' : ''} disabled={busy} onClick={() => void switchAuto()}>
<span className="popup-mode-icon"><Route size={15} /></span>
<span><strong></strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} · {sourceRuleCount.toLocaleString()} </small></span>
{state.proxyRuntime.dirty ? <em></em> : autoActive ? <Check size={14} /> : null}
</button>
{state.proxyProfiles.map((profile) => {
const active = state.activeProxyId === profile.id;
const active = status.activeProfileId === profile.id;
return <button key={profile.id} role="radio" aria-checked={active} className={active ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })), `${profile.name} 已作为全局模式启用`)}>
<span className="popup-mode-icon"><Network size={15} /></span>
<span><strong>{profile.name}</strong><small>{proxyDetail(profile)}</small></span>
+3
View File
@@ -25,6 +25,9 @@ async function bootstrap(): Promise<void> {
await request('bridge.managed-instance.bind', {
manager: manager as 'ytray' | 'yakit', instanceId, badge,
browserName: query.get('browserName') || undefined,
browserVersion: query.get('browserVersion') || undefined,
startupProxy: query.get('startupProxy') || undefined,
});
const current = await browser.tabs.getCurrent();
@@ -13,6 +13,7 @@ vi.mock('wxt/browser', () => {
const event = { addListener: vi.fn() };
return {
browser: {
storage: {},
tabs: { onRemoved: event, onCreated: event },
webNavigation: {
onBeforeNavigate: event,
+236 -14
View File
@@ -1,6 +1,7 @@
import { browser } from 'wxt/browser';
import * as v from 'valibot';
import {
createRecordedPageCallable,
getBrowserRecording,
} from '@/features/browser-recording/service';
import { recordingSnapshotForScope } from '@/features/browser-recording/redaction';
@@ -8,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,
@@ -34,6 +43,8 @@ import {
type BrowserTransformProfileProposalResult,
type BrowserTransformProfileValidationResult,
type BrowserTransformValidationDraft,
type BrowserTransformDirectionName,
type BrowserDeepCaptureMatcher,
} from '@/types/models';
const MAX_TRACE_EVENTS = 80;
@@ -65,6 +76,8 @@ let callableOutputStorageQueue: Promise<void> = Promise.resolve();
interface BrowserProfileEvidenceReference {
candidate: BrowserProfileInferenceCandidate;
requestEvent?: BrowserRecordingEvent;
matcher?: BrowserDeepCaptureMatcher;
triggerKey?: string;
createdAt: number;
expiresAt: number;
}
@@ -134,7 +147,7 @@ function pruneProfileEvidence(
);
}
export async function stageBrowserProfileEvidence(snapshot: BrowserRecordingSnapshot): Promise<void> {
export async function stageBrowserProfileEvidence(snapshot: BrowserRecordingSnapshot, triggerKey?: string): Promise<void> {
if (!snapshot.profileCandidates.length) return;
const scoped = recordingSnapshotForScope(snapshot, false);
const events = new Map(scoped.events.map((event) => [event.id, event]));
@@ -145,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,
};
@@ -192,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,
@@ -221,6 +238,13 @@ async function resolveStagedProfileCandidate(
return reference.candidate;
}
async function resolvePairedStagedProfileCandidate(
candidate: BrowserProfileInferenceCandidate,
): Promise<BrowserProfileInferenceCandidate | undefined> {
const references = Object.values(pruneProfileEvidence(await readStoredProfileEvidence()));
return pairedBrowserTransformCandidate(references.map((item) => item.candidate), candidate, true);
}
export async function resolveBrowserProfileCallableAnalysis(
target: BrowserTarget,
candidateId: string,
@@ -478,6 +502,37 @@ export async function latestBrowserTransformValidation(
return draft || memoryValidationDrafts.get(key) || null;
}
export async function browserTransformValidationById(
validationId: string,
): Promise<BrowserTransformValidationDraft> {
const now = Date.now();
const stored = await readStoredValidationDrafts();
const drafts = pruneValidationDrafts(stored, now);
if (Object.keys(drafts).length !== Object.keys(stored).length) {
validationDraftStorageQueue = validationDraftStorageQueue.then(() => writeStoredValidationDrafts(drafts));
await validationDraftStorageQueue;
}
const draft = Object.values(drafts).find((item) => item.id === validationId)
|| [...memoryValidationDrafts.values()].find((item) => item.id === validationId && item.expiresAt > now);
if (!draft) {
throw new ExtensionError('validation_draft_stale', '验证草稿不存在或已经过期,请重新生成并验证');
}
return draft;
}
export async function executeBrowserTransformValidation(
validationId: string,
direction: BrowserTransformDirectionName,
packet: BrowserTransformPacket,
): Promise<BrowserTransformExecution> {
const draft = await browserTransformValidationById(validationId);
const { profile, execution } = await validateBrowserTransformProfile(draft.profile, packet, {
direction,
profileId: `transient-${validationId}`,
});
return { ...execution, explanation: profile.explanation, proofLevel: draft.proofLevel };
}
export async function discardBrowserTransformValidation(
target: BrowserTarget,
validationId: string,
@@ -801,7 +856,7 @@ export function comparePacketWithInferenceCandidate(
check(
checks,
'body-shape',
'已关联的线上字段存在且没有二次 JSON 包装',
'线上字段结构一致(不验证加密前的输入内容)',
bodyFieldsPresent,
actualShape.signature,
bodyFields,
@@ -1039,12 +1094,28 @@ 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,
callableId: string,
inputPaths?: string[],
name?: string,
packet?: BrowserTransformPacket,
): Promise<BrowserTransformProfileProposalResult> {
const [snapshot, callables, tab, frame] = await Promise.all([
getBrowserRecording(target, 500, false),
@@ -1054,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;
@@ -1065,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;
@@ -1082,7 +1186,14 @@ export async function proposeBrowserTransformProfile(
lastAccessed: tab.lastAccessed,
};
const requestEvent = evidence.requestEvent;
let profile = createBrowserTransformProfileInput(tabInfo, requestEvent, callable, candidate);
let profile = createBrowserTransformProfileInput(
tabInfo,
requestEvent,
callable,
candidate,
inputPaths ? undefined : packet,
pairedCandidate && pairedCallable ? { candidate: pairedCandidate, callable: pairedCallable } : undefined,
);
profile = {
...profile,
name: name || profile.name,
@@ -1129,7 +1240,7 @@ export async function proposeBrowserTransformProfile(
? callable.transaction ? 'captured-request-transaction' : 'validated-callable-envelope'
: 'recording-evidence',
},
next: '调用 profile.validate;验证成功后由用户在插件中确认保存,AI 不直接持久化配置',
next: '调用 profile.validate;验证成功后可立即用 validationDraft.id 做一次临时明文 HTTP 测试,只有复用配置才需要用户在插件中确认保存',
};
}
@@ -1159,15 +1270,17 @@ export async function validateBrowserTransformProposal(
comparisonMode: 'structure' | 'exact' = 'structure',
candidateId?: string,
): Promise<BrowserTransformProfileValidationResult> {
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', '验证候选不属于明文网关绑定的页面');
}
@@ -1196,19 +1309,22 @@ export async function validateBrowserTransformProposal(
proofLevel,
normalizedProfile,
generated,
execution,
execution: { ...execution, explanation: normalized.explanation },
comparison,
validationDraft: validationDraft ? {
contractVersion: validationDraft.contractVersion,
id: validationDraft.id,
createdAt: validationDraft.createdAt,
expiresAt: validationDraft.expiresAt,
directions: { request: normalizedProfile.request.enabled, response: normalizedProfile.response.enabled },
} : undefined,
next: comparison
? comparison.equivalent
? '确定性验证通过;插件已生成待用户确认的明文网关草稿'
? comparison.mode === 'exact'
? '样本报文对比通过;尚未发送业务测试请求,使用 validationDraft.id 调用 browser.http.test'
: '仅结构校验通过,不证明明文输入、加密语义或业务成功;使用 validationDraft.id 调用 browser.http.test 验证,请勿原样重复 prepare'
: '数据包对比未通过;检查输入映射或重新选择页面函数'
: 'Pipeline 已真实回放并生成待确认草稿;如需更强证明,请提供一份浏览器线上请求进行结构对比',
: 'Pipeline 已真实回放;可将 validationDraft.id 直接交给 browser.http.test。如需更强证明,请提供一份浏览器线上请求进行结构对比',
};
}
@@ -1228,6 +1344,7 @@ export async function validateInferredBrowserTransformProfile(
callableId,
inputPaths,
name,
packet,
);
return validateBrowserTransformProposal(
proposal.profile,
@@ -1237,3 +1354,108 @@ export async function validateInferredBrowserTransformProfile(
candidateId,
);
}
const preparingTabs = new Set<number>();
interface PreparationCapture {
owner: DeepCaptureOwner;
trigger?: { captureId: string; nodeId: string };
authorize(): void;
}
async function captureMissingProfileCallable(
target: BrowserTarget,
candidate: BrowserProfileInferenceCandidate,
options?: PreparationCapture,
): Promise<BrowserPageCallable> {
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<BrowserTransformProfileValidationResult> {
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);
}
}
@@ -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', () => {
@@ -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;
@@ -60,6 +60,8 @@ function describe(
const cryptoJs = (scope.window as unknown as { CryptoJS?: Record<string, unknown> }).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<string, unknown>
: {};
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);
},
};
}
+206
View File
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const fixture = vi.hoisted(() => ({
executeScript: vi.fn(),
startRecording: vi.fn(),
stopRecording: vi.fn(),
startNetwork: vi.fn(),
listNetwork: vi.fn(),
stopNetwork: vi.fn(),
act: vi.fn(),
context: vi.fn(),
stageEvidence: vi.fn(),
}));
vi.mock('wxt/browser', () => ({
browser: { scripting: { executeScript: fixture.executeScript } },
}));
vi.mock('@/features/browser-recording/service', () => ({
startBrowserRecording: fixture.startRecording,
stopBrowserRecording: fixture.stopRecording,
}));
vi.mock('@/features/network-capture/service', () => ({
startNetworkCapture: fixture.startNetwork,
listNetworkRequests: fixture.listNetwork,
stopNetworkCapture: fixture.stopNetwork,
}));
vi.mock('@/features/page-context/service', () => ({
actOnPageNode: fixture.act,
capturePageContext: fixture.context,
}));
vi.mock('@/features/browser-analysis/service', () => ({
listRecordingTraces: vi.fn(() => [{ id: 'trace-1', cryptoCount: 1 }]),
stageBrowserProfileEvidence: fixture.stageEvidence,
}));
vi.mock('@/platform/browser/targets', () => ({
scriptingTarget: vi.fn((target) => ({ tabId: target.tabId, documentIds: [target.documentId] })),
}));
describe('atomic page crypto inspection', () => {
beforeEach(() => {
vi.clearAllMocks();
fixture.executeScript
.mockResolvedValueOnce([{ result: true }])
.mockResolvedValueOnce([{ result: [{ type: 'alert', message: 'done', decision: 'auto_dismissed', timestamp: 2 }] }]);
fixture.startRecording.mockResolvedValue({});
fixture.stopRecording.mockResolvedValue({
status: { target: { tabId: 7, frameId: 0, documentId: 'doc-1' }, active: false, documentAvailable: true, count: 1, droppedCount: 0 },
events: [{
id: 'event-1', sequence: 1, timestamp: 1, recordingId: 'recording-1', traceId: 'trace-1',
kind: 'crypto', operation: 'AES.encrypt',
crypto: { adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt', mode: 'CBC', padding: 'Pkcs7' },
inputs: [], outputs: [], sensitiveCaptured: true,
}],
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: [],
request: {
method: 'POST', url: 'https://example.test/api', bodyFormat: 'json',
mappings: [{ sourceEventId: 'event-1', destination: '$body.username' }],
},
}],
});
fixture.startNetwork.mockResolvedValue({});
fixture.listNetwork.mockResolvedValue([{
id: 'request-1', requestId: 'devtools-1', tabId: 7, frameId: 0,
url: 'https://example.test/api', method: 'POST', resourceType: 'xmlhttprequest',
startedAt: 1, completedAt: 2, statusCode: 200,
requestHeadersCaptured: false, requestBodyCaptured: true,
requestBody: { encoding: 'utf8', data: '{"cipher":"abc"}', byteLength: 16, truncated: false },
redirects: [],
}]);
fixture.stopNetwork.mockResolvedValue({});
fixture.act.mockResolvedValue({ action: 'click', status: 'dispatched', dispatchedAt: 1, node: { nodeId: 'n1' } });
fixture.context.mockResolvedValue({
captureId: 'capture-2',
target: { tabId: 7, frameId: 0, documentId: 'doc-1' },
authentication: { state: 'authenticated' },
document: {
title: 'Crypto lab', url: 'https://example.test/', readyState: 'complete', forms: [],
interactive: [{ nodeId: 'n2', role: 'button', name: 'Next operation', visible: true }],
},
});
});
afterEach(() => vi.useRealTimers());
it('captures one click, crypto evidence, request, and modal dialog in one call', async () => {
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).toMatchObject({
state: 'observed',
dialogs: [{ type: 'alert', message: 'done', decision: 'auto_dismissed' }],
dialogHandling: { autoDismissedAlerts: 1, autoAcceptedConfirms: 0, autoSubmittedPrompts: 0, count: 1, navigationInferred: false },
postAction: {
sameDocument: true,
captureId: 'capture-2',
document: { interactive: [{ nodeId: 'n2', name: 'Next operation' }] },
},
recording: { count: 1, events: [{ kind: 'crypto', operation: 'AES.encrypt' }] },
network: { count: 1, requests: [{ method: 'POST', statusCode: 200 }] },
gatewayPreparation: {
state: 'ready', candidateId: 'candidate-1',
request: { method: 'POST', destinations: ['$body.username'] },
},
});
expect(fixture.act).toHaveBeenCalledOnce();
expect(fixture.stopRecording).toHaveBeenCalledOnce();
expect(fixture.stopNetwork).toHaveBeenCalledOnce();
expect(fixture.stageEvidence).toHaveBeenCalledOnce();
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();
const delayedRequest = {
id: 'request-delayed', requestId: 'devtools-delayed', tabId: 7, frameId: 2,
url: 'https://example.test/delayed', method: 'POST', resourceType: 'xmlhttprequest',
startedAt: 1, completedAt: 2, statusCode: 200,
requestHeadersCaptured: false, requestBodyCaptured: false, redirects: [],
};
fixture.listNetwork.mockImplementation(async () => (Date.now() - startedAt >= 900 ? [delayedRequest] : []));
const { inspectPageCryptoOperation } = await import('./inspect');
let settled = false;
const pending = inspectPageCryptoOperation(
{ tabId: 7, frameId: 2, documentId: 'doc-frame' },
{ captureId: 'capture-1', nodeId: 'n1', settleMs: 1_500 },
{ grantId: 'paired', expiresAt: Date.now() + 60_000 },
).finally(() => { settled = true; });
await vi.advanceTimersByTimeAsync(800);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(800);
const result = await pending;
expect(result).toMatchObject({ network: { count: 1, requests: [{ id: 'request-delayed' }] } });
expect(fixture.context).toHaveBeenCalledWith({ includeDom: true }, { tabId: 7, frameId: 2 });
});
it('handles alert, confirm, and prompt without blocking the page', async () => {
const originalAlert = globalThis.alert;
const originalConfirm = globalThis.confirm;
const originalPrompt = globalThis.prompt;
const alert = vi.fn();
globalThis.alert = alert;
const confirm = vi.fn(() => false);
const prompt = vi.fn(() => 'typed value');
globalThis.confirm = confirm;
globalThis.prompt = prompt;
try {
const { installPageDialogCapture, restorePageDialogCapture } = await import('./inspect');
expect(installPageDialogCapture()).toBe(true);
globalThis.alert('notice');
expect(globalThis.confirm('continue?')).toBe(true);
expect(globalThis.prompt('name?')).toBe('');
expect(restorePageDialogCapture()).toMatchObject([
{ type: 'alert', decision: 'auto_dismissed' },
{ type: 'confirm', decision: 'auto_accepted' },
{ type: 'prompt', decision: 'auto_submitted' },
]);
expect(alert).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(prompt).not.toHaveBeenCalled();
} finally {
globalThis.alert = originalAlert;
globalThis.confirm = originalConfirm;
globalThis.prompt = originalPrompt;
}
});
});
+288
View File
@@ -0,0 +1,288 @@
import {
startBrowserRecording,
stopBrowserRecording,
} from '@/features/browser-recording/service';
import {
listRecordingTraces,
stageBrowserProfileEvidence,
} from '@/features/browser-analysis/service';
import {
listNetworkRequests,
startNetworkCapture,
stopNetworkCapture,
} from '@/features/network-capture/service';
import { actOnPageNode, capturePageContext } from '@/features/page-context/service';
import {
beginPageDialogCapture,
endPageDialogCapture,
installPageDialogCapture,
restorePageDialogCapture,
} from '@/features/page-context/dialogs';
import { ExtensionError } from '@/shared/errors';
import { pairedBrowserTransformCandidate } from '@/features/browser-transform/profile-draft';
import type {
BrowserRecordingEvent,
BrowserRecordingSnapshot,
BrowserTarget,
NetworkRequestRecord,
PageDialog,
PageNodeActionResult,
} from '@/types/models';
type InspectionOwner = { grantId: string; expiresAt: number };
export { installPageDialogCapture, restorePageDialogCapture };
function clipped(value: string | undefined, max: number): string | undefined {
return value === undefined ? undefined : value.slice(0, max);
}
function compactEvent(event: BrowserRecordingEvent): Record<string, unknown> {
return {
id: event.id,
traceId: event.traceId,
kind: event.kind,
operation: event.operation,
label: event.label,
crypto: event.crypto,
transform: event.transform,
direction: event.direction,
method: event.method,
statusCode: event.statusCode,
url: clipped(event.url, 2_048),
dataType: event.dataType,
byteLength: event.byteLength,
resultByteLength: event.resultByteLength,
scriptUrl: clipped(event.scriptUrl, 2_048),
stack: clipped(event.stack, 512),
callableCapable: event.callableCapable,
callHandleId: event.callHandleId,
arguments: event.arguments?.slice(0, 8),
inputs: event.inputs.slice(0, 12),
outputs: event.outputs.slice(0, 12),
inputPreview: clipped(event.inputPreview, 1_024),
outputPreview: clipped(event.outputPreview, 1_024),
error: event.error,
};
}
function compactRequest(request: NetworkRequestRecord): Record<string, unknown> {
return {
id: request.id,
method: request.method,
url: clipped(request.url, 4_096),
resourceType: request.resourceType,
statusCode: request.statusCode,
durationMs: request.durationMs,
error: request.error,
requestBody: request.requestBody && {
...request.requestBody,
data: clipped(request.requestBody.data, 2_048),
},
responseContentType: request.responseContentType,
responseSize: request.responseSize,
};
}
export function summarizeCryptoInspection(
snapshot: BrowserRecordingSnapshot,
requests: NetworkRequestRecord[],
): Record<string, unknown> {
const events = snapshot.events.slice(-16);
const cryptoCount = events.filter((event) => event.kind === 'crypto').length;
const transformCount = events.filter((event) => event.kind === 'transform').length;
const state = cryptoCount + transformCount > 0
? 'observed'
: events.length + requests.length > 0
? 'boundary_only'
: 'no_evidence';
return {
state,
summary: state === 'observed'
? `已观测到 ${cryptoCount} 次密码调用和 ${transformCount} 次编码/序列化转换`
: state === 'boundary_only'
? '已观测到页面或网络边界,但未命中已知加解密适配器'
: '本次页面操作没有产生可分析证据',
recording: {
count: snapshot.status.count,
droppedCount: snapshot.status.droppedCount,
events: events.map(compactEvent),
traces: listRecordingTraces(snapshot, 6),
},
network: {
count: requests.length,
requests: requests.slice(0, 8).map(compactRequest),
},
};
}
async function waitForInspectionIdle(target: BrowserTarget, maxWaitMs: number): Promise<NetworkRequestRecord[]> {
const startedAt = Date.now();
let lastChangedAt = startedAt;
let previousSignature = '';
let observedActivity = false;
let requests: NetworkRequestRecord[] = [];
while (Date.now() - startedAt < maxWaitMs) {
requests = await listNetworkRequests(target, 20);
const signature = requests.map((item) => `${item.id}:${item.completedAt || ''}:${item.error || ''}`).join('|');
if (signature !== previousSignature) {
previousSignature = signature;
lastChangedAt = Date.now();
}
if (requests.length > 0) observedActivity = true;
const allFinished = requests.every((item) => item.completedAt !== undefined || Boolean(item.error));
if (observedActivity && Date.now() - startedAt >= 500 && allFinished && Date.now() - lastChangedAt >= 350) break;
await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
}
return requests;
}
export async function inspectPageCryptoOperation(
target: BrowserTarget,
input: { captureId: string; nodeId: string; settleMs?: number },
owner: InspectionOwner,
): Promise<Record<string, unknown>> {
const startedAt = Date.now();
const settleMs = Math.max(250, Math.min(input.settleMs || 4_000, 5_000));
const warnings: string[] = [];
let action: PageNodeActionResult | undefined;
let snapshot: BrowserRecordingSnapshot | undefined;
let requests: NetworkRequestRecord[] = [];
let dialogs: PageDialog[] = [];
let postAction: Record<string, unknown> | undefined;
let recordingStarted = false;
let networkStarted = false;
let dialogCaptureOwned = false;
dialogCaptureOwned = await beginPageDialogCapture(target);
try {
await startNetworkCapture(target, {
captureHeaders: false,
captureBody: true,
maxEntries: 40,
maxBodyBytes: 8_192,
}, {
kind: 'grant',
grantId: owner.grantId,
expiresAt: owner.expiresAt,
followSameOriginNavigation: true,
});
networkStarted = true;
await startBrowserRecording(target, {
captureValues: true,
maxEntries: 160,
maxValueBytes: 4_096,
expiresAt: owner.expiresAt,
}, { kind: 'grant', grantId: owner.grantId, expiresAt: owner.expiresAt });
recordingStarted = true;
action = await actOnPageNode(input.captureId, input.nodeId, 'click', target);
requests = await waitForInspectionIdle(target, settleMs);
snapshot = await stopBrowserRecording(target, true);
recordingStarted = false;
} finally {
if (recordingStarted) {
try { snapshot = await stopBrowserRecording(target, true); } catch (error) {
warnings.push(`停止页面录制失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (networkStarted) {
try {
if (!requests.length) requests = await listNetworkRequests(target, 20);
await stopNetworkCapture(target);
} catch (error) {
warnings.push(`停止网络观察失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
dialogs = await endPageDialogCapture(target, dialogCaptureOwned);
try {
const context = await capturePageContext({ includeDom: true }, {
tabId: target.tabId,
frameId: target.frameId,
});
postAction = {
sameDocument: Boolean(target.documentId && context.target.documentId === target.documentId),
captureId: context.captureId,
target: context.target,
authentication: context.authentication,
document: {
title: context.document.title,
url: context.document.url,
readyState: context.document.readyState,
forms: context.document.forms,
interactive: context.document.interactive,
},
};
} catch (error) {
warnings.push(`采集操作后页面状态失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (!snapshot || !action) {
throw new ExtensionError('crypto_inspection_incomplete', '未能完整执行页面加解密检查');
}
await stageBrowserProfileEvidence(snapshot, action.node.semanticKey);
const preparation = snapshot.profileCandidates
.filter((candidate) => [candidate.source, ...candidate.sources]
.some((source) => Boolean(source.callHandleId)))
.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<typeof candidate> => 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,
target,
trigger: { captureId: input.captureId, nodeId: input.nodeId, action: 'click' },
action,
startedAt,
completedAt: Date.now(),
dialogs,
dialogHandling: {
strategy: 'nonblocking-local-dialog-defaults',
autoDismissedAlerts: dialogs.filter((dialog) => dialog.type === 'alert').length,
autoAcceptedConfirms: dialogs.filter((dialog) => dialog.type === 'confirm').length,
autoSubmittedPrompts: dialogs.filter((dialog) => dialog.type === 'prompt').length,
count: dialogs.length,
navigationInferred: false,
},
postAction,
gatewayPreparation: preparation ? {
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: preparationReady
? '需要明文 HTTP 测试时,直接调用 browser.transform.prepare;同一事务的请求与响应会编译进一个 Profile'
: '调用 browser.transform.prepare,插件将自动重触发本次操作、捕获缺失的业务方向并验证完整网关;不需要打开插件 UI',
} : {
state: 'unavailable',
next: '本次证据可用于分析,但不足以生成明文转换;继续使用当前页面,不要重新打开网站',
},
warnings,
...evidence,
purpose: '仅分析这一次页面操作;未创建、验证或保存明文网关 Profile',
};
}
@@ -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<BrowserRecordingEvent> & Pick<BrowserRecordingEvent, 'id' | 'sequence' | 'kind' | 'operation'>): 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);
});
});
+49 -9
View File
@@ -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,
'明文响应',
],
@@ -9,7 +9,7 @@ import { Switch } from '@/components/ui/switch';
import { errorMessage, request } from '@/platform/messaging/runtime';
import type {
ActiveTabInfo, BrowserPageCallable, BrowserPageCallableExecution, BrowserRecordingEvent,
BrowserProfileInferenceCandidate, BrowserRecordingArgumentRole, BrowserRecordingSnapshot,
BrowserProfileInferenceCandidate, BrowserRecordingArgumentRole, BrowserRecordingSnapshot, BrowserTarget,
} from '@/types/models';
import type { CapturedCallableSample } from '@/features/deep-capture/callable-sample';
import { DeepCaptureWorkspace } from '@/features/deep-capture/DeepCaptureWorkspace';
@@ -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<void>, success?: string) => Promise<void>;
const DEEP_CAPTURE_AVAILABLE = !import.meta.env.FIREFOX;
@@ -30,7 +35,6 @@ interface RecordingWorkspaceProps {
run: RunTask;
gatewayShared: boolean;
onShareGateway: () => Promise<void>;
initialMode?: 'gateway' | 'recording' | 'deep';
}
const KIND_LABELS: Record<BrowserRecordingEvent['kind'], string> = {
@@ -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 <Navigation size={15} />;
if (kind === 'interaction') return <Radio size={15} />;
@@ -155,13 +167,6 @@ function navigationPhaseLabel(event: BrowserRecordingEvent): string {
return '浏览器文档边界';
}
function emptySnapshot(tabId: number): BrowserRecordingSnapshot {
return {
status: { active: false, target: { tabId, frameId: 0 }, documentAvailable: true, count: 0, droppedCount: 0 },
events: [], traces: [], links: [], callables: [], profileCandidates: [],
};
}
function shortSample(event?: BrowserRecordingEvent): string | undefined {
const value = event?.inputPreview || event?.inputs.find((item) => item.preview)?.preview;
return value?.trim() || undefined;
@@ -169,23 +174,29 @@ function shortSample(event?: BrowserRecordingEvent): string | undefined {
function eventAvailableInDocument(
event: BrowserRecordingEvent | undefined,
currentDocumentId: string | undefined,
target: BrowserTarget | undefined,
documentAvailable: boolean,
): boolean {
return documentAvailable && Boolean(event) && (
!event?.documentId || !currentDocumentId || event.documentId === currentDocumentId
(event?.frameId === undefined || !target || event.frameId === target.frameId)
&& (!event?.documentId || !target?.documentId || event.documentId === target.documentId)
);
}
function recordingEventTarget(tabId: number | undefined, event?: BrowserRecordingEvent): BrowserTarget | undefined {
return tabId !== undefined && event
? { tabId, frameId: event.frameId ?? 0, documentId: event.documentId }
: undefined;
}
export function RecordingWorkspace({
tab,
busy,
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<BrowserPageCallableExecution>();
const [gatewaySuggestion, setGatewaySuggestion] = useState<BrowserTransformSuggestionSeed>();
const [pendingGatewayBinding, setPendingGatewayBinding] = useState<BrowserTransformProfileBinding>();
const [captureCandidate, setCaptureCandidate] = useState<BrowserProfileInferenceCandidate>();
useEffect(() => { setCaptureCandidate(undefined); }, [tab?.id]);
const load = useCallback(async () => {
const tabId = tab?.id;
@@ -210,11 +224,8 @@ export function RecordingWorkspace({
return;
}
try {
const target = { tabId, frameId: 0 };
const status = await request('recording.status', target);
const next = status.startedAt
? await request('recording.get', { ...target, limit: 500 })
: emptySnapshot(tabId);
const target = { tabId, frameId: 0, scope: 'tab' as const };
const next = await request('recording.get', { ...target, limit: 500 });
setSnapshot(next);
if (next.status.options) setCaptureValues(next.status.options.captureValues);
setLoadError('');
@@ -258,8 +269,8 @@ export function RecordingWorkspace({
const selectedEvent = snapshot?.events.find((event) => event.id === selectedEventId);
const selectedCallable = snapshot?.callables.find((callable) => callable.id === selectedCallableId);
const recordingTarget = tab ? { tabId: tab.id, frameId: 0 } : undefined;
const documentAvailable = snapshot?.status.documentAvailable !== false;
const callableTarget = snapshot?.status.startedAt && documentAvailable ? snapshot.status.target : undefined;
const tabRecordingTarget = recordingTarget ? { ...recordingTarget, scope: 'tab' as const } : undefined;
const selectedEventTarget = recordingEventTarget(tab?.id, selectedEvent);
useEffect(() => {
if (!selectedEvent) return;
@@ -278,32 +289,36 @@ export function RecordingWorkspace({
const start = () => run(async () => {
if (!tab) throw new Error('请选择目标标签页');
const next = await request('recording.start', {
tabId: tab.id, captureValues, maxEntries: 500, maxValueBytes: 8_192,
tabId: tab.id, scope: 'tab', captureValues, maxEntries: 500, maxValueBytes: 8_192,
});
setSnapshot(next);
setSelectedTraceId('');
setSelectedEventId('');
setCallableResult(undefined);
}, captureValues ? '录制已开始;短时样本仅保留在本次浏览器会话,页面跳转后会自动接续' : '录制已开始,将跨页面记录业务执行链');
setPendingGatewayBinding(undefined);
setCaptureCandidate(undefined);
}, captureValues ? '录制已开始;已覆盖标签页内所有页面,短时样本仅保留在本次浏览器会话' : '录制已开始;已覆盖标签页内所有页面与后续登录 frame');
const stop = () => run(async () => {
if (!recordingTarget) return;
setSnapshot(await request('recording.stop', recordingTarget));
if (!tabRecordingTarget) return;
setSnapshot(await request('recording.stop', tabRecordingTarget));
}, '录制已停止,可以继续验证页面函数');
const clear = () => run(async () => {
if (!recordingTarget) return;
setSnapshot(await request('recording.clear', recordingTarget));
if (!tabRecordingTarget) return;
setSnapshot(await request('recording.clear', tabRecordingTarget));
setCallableResult(undefined);
setPendingGatewayBinding(undefined);
setCaptureCandidate(undefined);
}, '录制与录制型页面函数已清空');
const createCallable = () => run(async () => {
if (snapshot?.status.active) throw new Error('请先停止录制,再保存页面函数');
if (!selectedEventAvailable || !callableTarget || !selectedEvent?.callHandleId) {
if (!selectedEventAvailable || !selectedEventTarget || !selectedEvent?.callHandleId) {
throw new Error(selectedEvent ? '该调用属于另一个页面文档;返回对应页面现场后才能保存' : '当前事件没有可执行调用句柄');
}
const callable = await request('callable.create', {
...callableTarget, source: 'recording', callHandleId: selectedEvent.callHandleId, name: callableName,
...selectedEventTarget, source: 'recording', callHandleId: selectedEvent.callHandleId, name: callableName,
});
setSnapshot((current) => current ? { ...current, callables: [...current.callables.filter((item) => item.id !== callable.id), callable] } : current);
setSelectedCallableId(callable.id);
@@ -312,17 +327,23 @@ export function RecordingWorkspace({
}, '页面函数已创建');
const executeCallable = () => run(async () => {
if (!callableTarget || !selectedCallable) throw new Error(documentAvailable ? '请选择页面函数' : '页面已经导航,旧文档的页面函数不可再执行');
if (!selectedCallable) throw new Error('请选择页面函数');
let args: unknown;
try { args = JSON.parse(callableArguments); } catch { throw new Error('调用参数必须是有效的 JSON 数组'); }
if (!Array.isArray(args)) throw new Error('调用参数必须是 JSON 数组');
setCallableResult(await request('callable.execute', { ...callableTarget, callableId: selectedCallable.id, args }));
setCallableResult(await request('callable.execute', { ...selectedCallable.target, callableId: selectedCallable.id, args }));
}, '页面函数验证完成');
const deleteCallable = () => run(async () => {
if (!callableTarget || !selectedCallable) return;
const callables = await request('callable.delete', { ...callableTarget, callableId: selectedCallable.id });
setSnapshot((current) => current ? { ...current, callables } : current);
if (!selectedCallable) return;
const callables = await request('callable.delete', { ...selectedCallable.target, callableId: selectedCallable.id });
setSnapshot((current) => current ? {
...current,
callables: [
...current.callables.filter((item) => item.target.frameId !== selectedCallable.target.frameId),
...callables,
],
} : current);
setCallableResult(undefined);
}, '页面函数已删除');
@@ -352,18 +373,35 @@ export function RecordingWorkspace({
? `页面函数句柄 ${(snapshot.status.retainedCallBytes / 1024).toFixed(1)} KiB`
: undefined,
].filter(Boolean).join(' · ');
const currentDocumentId = snapshot?.status.target.documentId;
const selectedEventAvailable = eventAvailableInDocument(selectedEvent, currentDocumentId, documentAvailable);
const documentAvailable = snapshot?.status.documentAvailable !== false;
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 selectedEventAvailable = eventAvailableInDocument(
selectedEvent,
selectedCandidate?.target || relatedCandidate?.target || selectedEventTarget,
selectedEvent?.frameId === 0 ? documentAvailable : true,
);
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;
const candidateAvailable = eventAvailableInDocument(candidateSourceEvent, currentDocumentId, documentAvailable);
const candidateAvailable = eventAvailableInDocument(candidateSourceEvent, selectedCandidate?.target, true);
const canDeepCapture = DEEP_CAPTURE_AVAILABLE && selectedEventAvailable && Boolean(selectedEvent
&& ['crypto', 'fetch', 'xhr', 'form', 'beacon', 'worker', 'message'].includes(selectedEvent.kind)
&& (selectedEvent.url || selectedEvent.wrapperHandleId));
@@ -375,14 +413,16 @@ export function RecordingWorkspace({
return;
}
void run(async () => {
if (!recordingTarget) throw new Error('目标标签页不可用');
setSnapshot(await request('recording.stop', recordingTarget));
if (!tabRecordingTarget) throw new Error('目标标签页不可用');
setSnapshot(await request('recording.stop', tabRecordingTarget));
setCallableEditorOpen(true);
}, '录制已停止,请确认页面函数名称');
};
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 +430,7 @@ export function RecordingWorkspace({
};
const openRecovery = (profileId: string) => {
setCaptureCandidate(undefined);
setRecoveryProfileId(profileId);
setAutoRecoveryRequest((current) => current + 1);
setWorkspaceMode('deep');
@@ -407,6 +448,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 (!snapshot || !eventAvailableInDocument(pairEvent, pair.target, true)) {
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', {
...pair.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 +486,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
@@ -436,28 +514,31 @@ export function RecordingWorkspace({
if (candidate.sources.length !== 1) {
throw new Error('多调用请求需要先捕获上层业务函数,不能把相互依赖的低层调用拆开回放');
}
if (!candidateAvailable || !recordingTarget || !candidate.source.callHandleId) {
if (!candidateAvailable || !tabRecordingTarget || !candidate.source.callHandleId) {
throw new Error(candidateAvailable ? '推断候选没有可复用的页面调用句柄' : '该函数属于另一个页面文档,请返回对应页面现场后再生成');
}
let currentSnapshot = snapshot;
if (currentSnapshot?.status.active) {
currentSnapshot = await request('recording.stop', recordingTarget);
currentSnapshot = await request('recording.stop', tabRecordingTarget);
setSnapshot(currentSnapshot);
}
if (!currentSnapshot) throw new Error('没有可用的录制现场');
const target = currentSnapshot.status.target;
const target = candidate.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 <section className="recording-section">
<div className="recording-heading">
@@ -476,7 +557,7 @@ export function RecordingWorkspace({
</div>
<div id="recording-mode-panel" className="recording-mode-panel" role="tabpanel" aria-labelledby="recording-mode-tab" hidden={workspaceMode !== 'recording'}><div className="recording-controls">
<label><Switch checked={captureValues} disabled={active || busy} onCheckedChange={setCaptureValues} /><span><strong></strong><small></small></span></label>
<label><Switch checked={captureValues} disabled={active || busy} onCheckedChange={setCaptureValues} /><span><strong></strong><small> frame</small></span></label>
<span className="recording-summary" title={persistenceTitle}>{snapshot?.traces.length || 0} Trace · {snapshot?.links.length || 0} · {snapshot?.callables.length || 0} · {persistenceLabel}{retentionDrops ? ` · ${retentionDrops} 项按预算丢弃` : ''}</span>
<Button size="icon" variant="ghost" aria-label="刷新录制" title="刷新录制" disabled={!tab} onClick={() => void load()}><RefreshCw size={15} /></Button>
<Button size="icon" variant="ghost" aria-label="清空录制" title="清空录制" disabled={!hasRecording || busy} onClick={() => void clear()}><Trash2 size={15} /></Button>
@@ -520,12 +601,18 @@ export function RecordingWorkspace({
<div className="recording-pipeline__body">
{!traceEvents.length ? <div className="recording-column-empty"> Trace </div> : 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 callableAvailable = eventAvailableInDocument(
event,
snapshot?.profileCandidates.find((candidate) => candidate.source.eventId === event.id)?.target
|| recordingEventTarget(tab?.id, event),
event.frameId === 0 ? documentAvailable : true,
);
const flowDirection = recordingEventDirection(event, traceCandidates);
return <div className={`recording-pipeline-step ${event.kind === 'navigation' ? 'is-navigation' : ''}`} key={event.id}>
<span className="recording-step-rail" aria-hidden="true"><i>{String(index + 1).padStart(2, '0')}</i>{index < traceEvents.length - 1 ? <span><ArrowDown size={11} /></span> : null}</span>
<button data-event-id={event.id} className={`${event.id === selectedEventId ? 'is-selected' : ''} ${linked ? 'is-linked' : ''}`} onClick={() => setSelectedEventId(event.id)}>
<span className={`recording-event-icon kind-${event.kind}`}>{eventIcon(event.kind)}</span>
<span><small>{KIND_LABELS[event.kind]}</small><strong>{eventTitle(event)}</strong><em>{eventSubtitle(event)}</em>{event.kind === 'navigation' ? <b>{navigationPhaseLabel(event)}</b> : null}</span>
<span><small className={flowDirection ? `is-${flowDirection}` : ''}>{flowDirection === 'request' ? '↑ 请求' : flowDirection === 'response' ? '↓ 响应' : KIND_LABELS[event.kind]}{flowDirection ? ` · ${KIND_LABELS[event.kind]}` : ''}</small><strong>{eventTitle(event)}</strong><em>{eventSubtitle(event)}</em>{event.kind === 'navigation' ? <b>{navigationPhaseLabel(event)}</b> : null}</span>
<span className="recording-event-meta">{event.callableCapable ? <i className={callableAvailable ? '' : 'is-history'}>{callableAvailable ? '当前可用' : '历史现场'}</i> : null}<time title={new Date(event.timestamp).toLocaleString()}>{relativeTime(event.timestamp, selectedTrace?.startedAt)}</time>{event.durationMs !== undefined ? <small>{event.durationMs.toFixed(1)} ms</small> : null}</span>
</button>
</div>;
@@ -535,7 +622,7 @@ export function RecordingWorkspace({
<aside className="recording-inspector">
{!selectedEvent ? <div className="recording-column-empty"> Pipeline </div> : <>
<header><div><span>{KIND_LABELS[selectedEvent.kind]}</span><strong>{eventTitle(selectedEvent)}</strong><small title={selectedEvent.url || selectedEvent.scriptUrl}>{selectedEvent.url || selectedEvent.scriptUrl || '页面主世界'}</small></div>{selectedEvent.error ? <i className="is-error">ERROR</i> : <i>#{selectedEvent.sequence}</i>}</header>
<header><div><span>{selectedEventDirection === 'request' ? '↑ 请求' : selectedEventDirection === 'response' ? '↓ 响应' : KIND_LABELS[selectedEvent.kind]}</span><strong>{eventTitle(selectedEvent)}</strong><small title={selectedEvent.url || selectedEvent.scriptUrl}>{selectedEvent.url || selectedEvent.scriptUrl || '页面主世界'}</small></div>{selectedEvent.error ? <i className="is-error">ERROR</i> : <i>#{selectedEvent.sequence}</i>}</header>
{selectedEvent.kind === 'navigation' && selectedEvent.navigation
? <dl className="recording-navigation-detail">
<div><dt></dt><dd>{navigationPhaseLabel(selectedEvent)}</dd></div>
@@ -545,10 +632,15 @@ export function RecordingWorkspace({
</dl>
: <dl><div><dt></dt><dd>{selectedEvent.byteLength === undefined ? `${selectedEvent.inputs.length} 个值` : `${selectedEvent.byteLength} B`}</dd></div><div><dt></dt><dd>{selectedEvent.resultByteLength === undefined ? `${selectedEvent.outputs.length} 个值` : `${selectedEvent.resultByteLength} B`}</dd></div><div><dt></dt><dd>{incomingLinks.length}</dd></div><div><dt></dt><dd>{outgoingLinks.length}</dd></div></dl>}
{relatedCandidate && relatedSourceEvent && <section className="recording-related-transform">
<div><Link2 size={15} /><span><strong>{relatedCandidate.direction === 'request' ? '请求' : '响应'}</strong><small>#{relatedSourceEvent.sequence} · {eventTitle(relatedSourceEvent)}</small></span></div>
<Button variant="ghost" onClick={() => setSelectedEventId(relatedSourceEvent.id)}>{relatedCandidate.direction === 'request' ? '请求' : '响应'}</Button>
</section>}
{selectedCandidate && <section className={`profile-inference is-${selectedCandidate.confidence.level}`}>
<div className="profile-inference__heading">
<span className="profile-inference__mark"><Sparkles size={15} /></span>
<span><small> Profile</small><strong>{selectedCandidate.summary}</strong></span>
<span><small> · {selectedCandidate.direction === 'request' ? '请求转换' : '响应转换'}</small><strong>{selectedCandidate.summary}</strong></span>
<i><ShieldCheck size={12} />{confidenceLabel(selectedCandidate)}</i>
</div>
<div className="profile-inference__flow" aria-label="推断的数据流">
@@ -556,6 +648,11 @@ export function RecordingWorkspace({
<code>{item}</code>{index < selectedCandidate.flow.length - 1 ? <ChevronRight size={12} /> : null}
</span>)}
</div>
<dl className="profile-inference__arguments">
<div><dt></dt><dd>{candidateStatusLabel(selectedCandidate.direction === 'request' ? selectedCandidate : pairedCandidate?.direction === 'request' ? pairedCandidate : undefined)}</dd></div>
<div><dt></dt><dd>{candidateStatusLabel(selectedCandidate.direction === 'response' ? selectedCandidate : pairedCandidate?.direction === 'response' ? pairedCandidate : undefined)}</dd></div>
</dl>
{pendingGatewayBinding && <div className="profile-inference__next is-ready"><span>{pendingGatewayBinding.candidate.direction === 'request' ? '请求转换' : '响应转换'}</span></div>}
{selectedCandidate.sources.length > 1 && <div className="profile-inference__sources">
{selectedCandidate.sources.map((source, index) => <div key={source.eventId}>
<span>{String(index + 1).padStart(2, '0')}</span>
@@ -573,27 +670,28 @@ export function RecordingWorkspace({
<summary>{selectedCandidate.evidence.length} </summary>
<ol>{selectedCandidate.evidence.map((item) => <li key={item.id} data-strength={item.strength}><i />{item.label}</li>)}</ol>
</details>
{selectedCandidate.missing[0] && <div className="profile-inference__next"><span>{selectedCandidate.missing[0].label}</span>
{selectedCandidate.missing[0].action === 'capture-business-function' && DEEP_CAPTURE_AVAILABLE
? <Button variant="primary" onClick={() => continueInference(selectedCandidate)}><Sparkles size={14} />{selectedCandidate.direction === 'response' ? '自动捕获完整解密流程' : '自动捕获完整加密流程'}</Button>
: null}
{gatewayNextStep && <div className={`profile-inference__next ${gatewayNextStep.kind === 'create' ? 'is-ready' : ''}`}><span>{candidateAvailable ? gatewayNextStep.description : '关联证据仍然保留;该页面函数属于另一个文档,返回对应页面现场后可以继续。'}</span>
{gatewayNextStep.kind === 'capture' && DEEP_CAPTURE_AVAILABLE
? <Button variant="primary" disabled={busy || !candidateAvailable} onClick={() => continueInference(gatewayNextStep.candidate)}><Sparkles size={14} />{candidateAvailable ? gatewayNextStep.label : '等待对应页面'}</Button>
: gatewayNextStep.kind === 'create'
? <Button variant="primary" disabled={busy || !candidateAvailable} onClick={() => void createSuggestedGateway(selectedCandidate)}><FileKey2 size={14} />{candidateAvailable ? gatewayNextStep.label : '等待对应页面'}</Button>
: null}
</div>}
{selectedCandidate.status === 'ready' && <div className="profile-inference__next is-ready"><span>{candidateAvailable ? (selectedCandidate.direction === 'response' ? '线上响应字段与页面解密调用已经精确关联,可直接生成响应明文网关。' : '页面调用与线上字段已经精确关联,只需确认明文来源和输出形态。') : '关联证据仍然保留;该页面函数属于另一个文档,返回对应页面现场后可以继续生成。'}</span><Button variant="primary" disabled={busy || !candidateAvailable} onClick={() => void createSuggestedGateway(selectedCandidate)}><FileKey2 size={14} />{candidateAvailable ? '生成明文网关' : '等待对应页面'}</Button></div>}
</section>}
{(selectedEvent.inputPreview || selectedEvent.outputPreview) && <div className="recording-values"><strong></strong>{selectedEvent.inputPreview && <pre>{selectedEvent.inputPreview}</pre>}{selectedEvent.outputPreview && <pre>{selectedEvent.outputPreview}</pre>}</div>}
{selectedEvent.kind !== 'navigation' ? <details className="recording-evidence"><summary></summary><pre>{selectedEvent.stack || selectedEvent.scriptUrl || '没有可用调用栈'}</pre></details> : null}
{canDeepCapture && !selectedCandidate && <section className="recording-deep-action">
{canDeepCapture && !selectedCandidate && !relatedCandidate && <section className="recording-deep-action">
<div><Bug size={15} /><span><strong></strong><small>{selectedEvent.kind === 'crypto'
? '下次命中当前加密调用时暂停'
: selectedEvent.kind === 'worker' || selectedEvent.kind === 'message' || selectedEvent.kind === 'beacon'
? '下次命中当前页面通信边界时暂停'
: '下次发出当前请求时暂停'}</small></span></div>
<Button variant="primary" onClick={() => setWorkspaceMode('deep')}><Bug size={14} /></Button>
<Button variant="primary" onClick={() => { setCaptureCandidate(undefined); setWorkspaceMode('deep'); }}><Bug size={14} /></Button>
</section>}
{selectedEvent.callableCapable && selectedEvent.callHandleId && <section className="recording-recipe-action">
{selectedEvent.callableCapable && selectedEvent.callHandleId && !selectedCandidate && <section className="recording-recipe-action">
<div><KeyRound size={15} /><span><strong></strong><small>{!selectedEventAvailable ? '该调用属于另一个页面文档,返回对应页面后可以恢复' : active ? '保存前会先停止录制,避免轮询继续改变调用现场' : '保留原函数、receiver 与固定参数,页面刷新后失效'}</small></span></div>
{!callableEditorOpen ? <Button variant="primary" disabled={busy || !selectedEventAvailable} onClick={prepareCallableEditor}><Save size={14} />{active ? '停止录制并保存' : '保存页面函数'}</Button> : <div className="recording-recipe-editor">
<label><span></span><input value={callableName} onChange={(event) => setCallableName(event.target.value)} /></label>
@@ -613,8 +711,9 @@ export function RecordingWorkspace({
{DEEP_CAPTURE_AVAILABLE && <div id="deep-mode-panel" className="recording-mode-panel" role="tabpanel" aria-labelledby="deep-mode-tab" hidden={workspaceMode !== 'deep'}>
<DeepCaptureWorkspace
tab={tab}
recordingTarget={selectedCandidate?.target || relatedCandidate?.target || selectedEventTarget}
selectedEvent={selectedEvent}
selectedCandidate={selectedCandidate}
selectedCandidate={captureCandidate || selectedCandidate}
autoArmRequest={autoArmRequest}
recoveryProfileId={recoveryProfileId}
autoRecoveryRequest={autoRecoveryRequest}
@@ -633,7 +732,7 @@ export function RecordingWorkspace({
run={run}
gatewayShared={gatewayShared}
onShareGateway={onShareGateway}
onOpenCapture={() => { setRecoveryProfileId(''); setWorkspaceMode(DEEP_CAPTURE_AVAILABLE ? 'deep' : 'recording'); }}
onOpenCapture={() => { setCaptureCandidate(undefined); setRecoveryProfileId(''); setWorkspaceMode(DEEP_CAPTURE_AVAILABLE ? 'deep' : 'recording'); }}
onOpenRecovery={DEEP_CAPTURE_AVAILABLE ? openRecovery : () => setWorkspaceMode('recording')}
deepCaptureAvailable={DEEP_CAPTURE_AVAILABLE}
recoveryRevision={recoveryRevision}
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { recordingExpiryDelay } from './expiry';
describe('recording expiry timer', () => {
it('does not overflow a permanent paired-browser grant into an immediate timeout', () => {
expect(recordingExpiryDelay(Number.MAX_SAFE_INTEGER, 1_000)).toBeUndefined();
expect(recordingExpiryDelay(11_000, 1_000)).toBe(10_000);
expect(recordingExpiryDelay(999, 1_000)).toBe(0);
});
});
+8
View File
@@ -0,0 +1,8 @@
const MAX_BROWSER_TIMER_MS = 0x7fffffff;
export function recordingExpiryDelay(expiresAt: number | undefined, now = Date.now()): number | undefined {
if (expiresAt === undefined) return undefined;
const delay = expiresAt - now;
if (delay <= 0) return 0;
return delay <= MAX_BROWSER_TIMER_MS ? delay : undefined;
}
@@ -2,6 +2,7 @@ import type {
BrowserRecordingEventKind,
BrowserRecordingValueEvidence,
} from '@/types/models';
import { readRequestBody } from '@/shared/request-body';
type NetworkKind = Extract<BrowserRecordingEventKind, 'fetch' | 'xhr' | 'form' | 'websocket'>;
@@ -333,8 +334,9 @@ export function createNetworkBoundaryRuntime(
const request = RequestConstructor && input instanceof RequestConstructor ? input : undefined;
const url = absoluteRequestUrl(request || input);
const method = (init?.method || request?.method || 'GET').toUpperCase().slice(0, 32);
bestEffort(() => {
const body = init?.body;
let stack: ReturnType<NetworkBoundaryHost['stackInfo']> = {};
bestEffort(() => { stack = host.stackInfo(); });
const emitRequest = (body: unknown) => bestEffort(() => {
host.emit({
kind: 'fetch',
operation: 'request',
@@ -350,9 +352,15 @@ export function createNetworkBoundaryRuntime(
...headerEvidence(init?.headers || request?.headers, '$headers'),
...queryEvidence(request || input),
],
...host.stackInfo(),
...stack,
}, context);
});
if (request && init?.body === undefined) {
void readRequestBody(request, MAX_ASYNC_BINARY_BYTES).then(
(body) => emitRequest(body.value),
() => emitRequest(undefined),
);
} else emitRequest(init?.body);
let result: ReturnType<typeof scope.fetch>;
try {
result = Reflect.apply(original, this, [input, init]);
@@ -41,6 +41,18 @@ function environment() {
}
describe('recording trace runtime', () => {
it('inherits unique value provenance across a later interaction without guessing shared values', () => {
const { runtime, setMaxEntries } = environment();
setMaxEntries(20);
const value = { path: '$body', fingerprint: 'ciphertext', encoding: 'text' as const, byteLength: 32 };
const key = { ...value, fingerprint: 'shared-key' };
runtime.bindContext({ traceId: 'B' });
runtime.record({ kind: 'fetch', operation: 'response', outputs: [value, key] }, { traceId: 'A' });
runtime.record({ kind: 'fetch', operation: 'response', outputs: [key] }, { traceId: 'C' });
expect(runtime.record({ kind: 'crypto', operation: 'decrypt', inputs: [value, key] })?.traceId).toBe('A');
runtime.record({ kind: 'fetch', operation: 'response', outputs: [value] }, { traceId: 'C' });
expect(runtime.record({ kind: 'crypto', operation: 'decrypt', inputs: [value] })?.traceId).toBe('B');
});
it('does not create events or trace state while recording is inactive', () => {
const environmentState = environment();
environmentState.setActive(false);
@@ -91,7 +91,19 @@ export function createRecordingTraceRuntime(
): BrowserRecordingEvent | undefined => {
const recordingId = host.recordingId();
if (!host.active() || !recordingId) return undefined;
const eventContext = explicitContext || context();
// Values carry causality across await and intervening user interactions.
// Only a unique recorded origin can override the current interaction.
const fingerprints = new Set((input.inputs || [])
.filter((value) => value.byteLength >= 8).map((value) => value.fingerprint));
let origins: Map<string, RecordingTraceContext> | undefined;
for (const fingerprint of fingerprints) {
const matches = new Map(events.filter((event) => event.outputs.some((value) => value.fingerprint === fingerprint))
.map((event) => [event.traceId, { traceId: event.traceId, interactionId: event.interactionId }]));
if (!matches.size) continue;
origins = origins ? new Map([...origins].filter(([traceId]) => matches.has(traceId))) : matches;
}
const inherited = origins?.size === 1 ? origins.values().next().value : undefined;
const eventContext = explicitContext || inherited || context();
sequence += 1;
const item: BrowserRecordingEvent = {
id: host.unique('event'),
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import type { BrowserProfileInferenceCandidate, BrowserRecordingEvent } from '@/types/models';
import { browserGatewayNextStep, recordingEventDirection } from './presentation';
const event = (id: string, kind: BrowserRecordingEvent['kind'], operation: string): BrowserRecordingEvent => ({
id, kind, operation, sequence: 1, timestamp: 1, recordingId: 'recording-1', traceId: 'trace-1',
inputs: [], outputs: [], sensitiveCaptured: false,
});
const candidate = (direction: 'request' | 'response', status: BrowserProfileInferenceCandidate['status']): BrowserProfileInferenceCandidate => ({
id: `candidate-${direction}`, recordingId: 'recording-1', traceId: 'trace-1',
target: { tabId: 1, frameId: 0 }, direction,
request: { eventId: `${direction}-boundary`, method: 'POST', url: 'https://example.test/login', bodyFormat: 'json', mappings: [] },
source: { eventId: `${direction}-crypto`, kind: 'crypto', operation: direction === 'request' ? 'AES.encrypt' : 'AES.decrypt', arguments: [] },
sources: [], status, confidence: { score: 100, level: 'high' }, summary: '', flow: [], pipeline: [], evidence: [{
id: `${direction}-evidence`, kind: direction === 'request' ? 'request-boundary' : 'response-boundary', strength: 'proven',
label: '', eventIds: [`${direction}-boundary`, `${direction}-crypto`, `${direction}-transform`],
}], missing: status === 'capture-required' ? [{ kind: 'business-callable', label: 'capture', action: 'capture-business-function' }] : [],
aiContext: {
valuePolicy: 'metadata-only',
request: { eventId: `${direction}-boundary`, method: 'POST', url: 'https://example.test/login' },
source: { eventId: `${direction}-crypto`, kind: 'crypto', operation: direction === 'request' ? 'AES.encrypt' : 'AES.decrypt', arguments: [] },
sources: [], evidenceIds: [], requiredDecision: status === 'ready' ? 'none' : 'capture-business-callable',
},
});
describe('recording presentation', () => {
it('labels network boundaries and linked transform events by direction', () => {
const request = candidate('request', 'ready');
const response = candidate('response', 'ready');
expect(recordingEventDirection(event('request-boundary', 'fetch', 'request'), [request, response])).toBe('request');
expect(recordingEventDirection(event('response-boundary', 'fetch', 'response'), [request, response])).toBe('response');
expect(recordingEventDirection(event('response-transform', 'transform', 'Hex.parse'), [request, response])).toBe('response');
expect(recordingEventDirection(event('unrelated', 'transform', 'JSON.stringify'), [request, response])).toBeUndefined();
});
it('offers the next incomplete direction instead of claiming the gateway is finished', () => {
const request = candidate('request', 'capture-required');
const response = candidate('response', 'ready');
expect(browserGatewayNextStep(response, request)).toMatchObject({
kind: 'capture', candidate: request, label: '继续捕获请求方向',
});
expect(browserGatewayNextStep(response, { ...request, status: 'ready' })).toMatchObject({
kind: 'create', label: '生成双向协议网关',
});
expect(browserGatewayNextStep(response)).toMatchObject({
kind: 'create', label: '生成仅响应网关',
});
});
});
@@ -0,0 +1,63 @@
import type { BrowserProfileInferenceCandidate, BrowserRecordingEvent } from '@/types/models';
export type RecordingEventDirection = 'request' | 'response';
const HTTP_EVENT_KINDS = new Set<BrowserRecordingEvent['kind']>(['fetch', 'xhr', 'form', 'beacon']);
export function recordingEventDirection(
event: BrowserRecordingEvent,
candidates: BrowserProfileInferenceCandidate[],
): RecordingEventDirection | undefined {
if (HTTP_EVENT_KINDS.has(event.kind)) {
return event.operation === 'response' || event.operation.startsWith('response.')
? 'response'
: 'request';
}
const directions = new Set(candidates.filter((candidate) => (
candidate.source.eventId === event.id
|| candidate.sources.some((source) => source.eventId === event.id)
|| candidate.evidence.some((evidence) => evidence.eventIds.includes(event.id))
)).map((candidate) => candidate.direction));
return directions.size === 1 ? [...directions][0] : undefined;
}
export type BrowserGatewayNextStep = {
kind: 'capture' | 'create' | 'blocked';
candidate: BrowserProfileInferenceCandidate;
label?: string;
description: string;
};
const directionName = (direction: BrowserProfileInferenceCandidate['direction']) => (
direction === 'request' ? '请求' : '响应'
);
export function browserGatewayNextStep(
candidate: BrowserProfileInferenceCandidate,
paired?: BrowserProfileInferenceCandidate,
): BrowserGatewayNextStep {
if (candidate.status !== 'ready') {
return candidate.status === 'capture-required'
? {
kind: 'capture', candidate, label: `继续捕获${directionName(candidate.direction)}方向`,
description: `需要先捕获完整的${directionName(candidate.direction)}转换。`,
}
: { kind: 'blocked', candidate, description: candidate.missing[0]?.label || '当前转换证据还不完整。' };
}
if (!paired) return {
kind: 'create', candidate, label: `生成仅${directionName(candidate.direction)}网关`,
description: `当前操作只检测到${directionName(candidate.direction)}转换,将生成单向协议网关。`,
};
if (paired.status === 'capture-required') return {
kind: 'capture', candidate: paired, label: `继续捕获${directionName(paired.direction)}方向`,
description: `${directionName(candidate.direction)}方向已就绪,还需要捕获${directionName(paired.direction)}方向。`,
};
if (paired.status !== 'ready') return {
kind: 'blocked', candidate: paired,
description: paired.missing[0]?.label || `${directionName(paired.direction)}转换证据还不完整。`,
};
return {
kind: 'create', candidate, label: '生成双向协议网关',
description: '请求和响应转换都已就绪,将合并为一个双向协议网关。',
};
}
+107 -10
View File
@@ -20,7 +20,8 @@ interface RawSnapshot {
const fixture = vi.hoisted(() => ({
storage: new Map<string, unknown>(),
storageFailure: undefined as Error | undefined,
pages: new Map<number, RawSnapshot>(),
pages: new Map<number | string, RawSnapshot>(),
frames: new Map<number, Array<{ tabId: number; frameId: number; documentId: string; url: string; accessible: boolean }>>(),
listeners: {} as Record<string, Listener>,
storageSet: vi.fn(),
}));
@@ -48,11 +49,16 @@ vi.mock('wxt/browser', () => {
runtime: { sendMessage: vi.fn(async () => undefined) },
scripting: {
executeScript: vi.fn(async (details: Record<string, any>) => {
if (details.files) return [{ frameId: 0 }];
const tabId = details.target.tabId as number;
const documentId = details.target.documentIds?.[0] as string | undefined;
const frameId = details.target.frameIds?.[0]
?? fixture.frames.get(tabId)?.find((frame) => frame.documentId === documentId)?.frameId
?? 0;
if (details.files) return [{ frameId }];
const command = details.args?.[2] as string;
const input = (details.args?.[3] || {}) as Record<string, unknown>;
const current = fixture.pages.get(tabId) || rawSnapshot(tabId);
const pageKey = frameId === 0 ? tabId : `${tabId}:${frameId}`;
const current = fixture.pages.get(pageKey) || rawSnapshot(tabId);
if (command === 'start') {
current.active = true;
current.recordingId = typeof input.recordingId === 'string' ? input.recordingId : `recording-${tabId}`;
@@ -67,8 +73,8 @@ vi.mock('wxt/browser', () => {
} else if (command === 'clear') {
Object.assign(current, rawSnapshot(tabId));
}
fixture.pages.set(tabId, current);
return [{ frameId: 0, result: clone(current) }];
fixture.pages.set(pageKey, current);
return [{ frameId, result: clone(current) }];
}),
},
tabs: {
@@ -84,13 +90,19 @@ vi.mock('wxt/browser', () => {
onCreated: event('created'),
},
cookies: {
getAllCookieStores: vi.fn(async () => [{ id: 'store-default', tabIds: [...fixture.pages.keys()] }]),
getAllCookieStores: vi.fn(async () => [{
id: 'store-default',
tabIds: [...new Set([...fixture.pages.keys()].map((key) => Number(String(key).split(':')[0])))],
}]),
},
webNavigation: {
getFrame: vi.fn(async ({ tabId }: { tabId: number }) => ({
url: `https://site-${tabId}.example.test/page`,
documentId: `document-${tabId}`,
})),
getFrame: vi.fn(async ({ tabId, frameId = 0 }: { tabId: number; frameId?: number }) => {
const frame = fixture.frames.get(tabId)?.find((item) => item.frameId === frameId);
return frame || {
url: `https://site-${tabId}.example.test/page`,
documentId: `document-${tabId}`,
};
}),
onBeforeNavigate: event('beforeNavigate'),
onCommitted: event('committed'),
onDOMContentLoaded: event('domContentLoaded'),
@@ -103,6 +115,25 @@ vi.mock('wxt/browser', () => {
};
});
vi.mock('@/features/page-context/frames', () => ({
getFrameInventory: vi.fn(async (tabId: number) => fixture.frames.get(tabId) || [{
tabId,
frameId: 0,
documentId: `document-${tabId}`,
parentFrameId: -1,
url: `https://site-${tabId}.example.test/page`,
origin: `https://site-${tabId}.example.test`,
title: 'Main frame',
name: '',
frameType: 'outermost_frame',
documentLifecycle: 'active',
isTop: true,
sameOrigin: true,
accessible: true,
sandbox: [],
}]),
}));
function rawSnapshot(tabId: number, events: Array<Record<string, unknown>> = []): RawSnapshot {
return {
version: 9,
@@ -159,6 +190,7 @@ describe('browser recording storage, snapshot and retained-value budgets', () =>
vi.setSystemTime(4_102_444_800_000);
fixture.storage.clear();
fixture.pages.clear();
fixture.frames.clear();
fixture.storageFailure = undefined;
fixture.storageSet.mockClear();
});
@@ -256,4 +288,69 @@ describe('browser recording storage, snapshot and retained-value budgets', () =>
globalSessionCount: 1,
});
});
it('records and merges requests from cross-origin child frames at tab scope', async () => {
const tabId = 30;
fixture.frames.set(tabId, [
{
tabId, frameId: 0, documentId: 'document-30', url: 'https://www.jd.com/', accessible: true,
},
{
tabId, frameId: 4, documentId: 'document-passport', url: 'https://passport.jd.com/new/login.aspx', accessible: true,
},
]);
fixture.pages.set(tabId, rawSnapshot(tabId, [recordingEvent(1)]));
fixture.pages.set(`${tabId}:4`, rawSnapshot(tabId, [{
...recordingEvent(2),
traceId: 'trace-passport-login',
url: 'https://passport.jd.com/uc/loginService?aksParamsU=redacted',
}]));
const service = await freshService();
const snapshot = await service.startTabBrowserRecording(tabId, { captureValues: true });
expect(snapshot.status).toMatchObject({ active: true, scope: 'tab' });
expect(snapshot.events).toEqual(expect.arrayContaining([
expect.objectContaining({ frameId: 0 }),
expect.objectContaining({
frameId: 4,
url: 'https://passport.jd.com/uc/loginService?aksParamsU=redacted',
}),
]));
expect(snapshot.traces.some((trace) => trace.label === 'POST /uc/loginService')).toBe(true);
});
it('automatically attaches a login frame created after recording starts', async () => {
const tabId = 31;
const top = {
tabId, frameId: 0, documentId: 'document-31', url: 'https://www.jd.com/', accessible: true,
};
fixture.frames.set(tabId, [top]);
fixture.pages.set(tabId, rawSnapshot(tabId));
const service = await freshService();
await service.startTabBrowserRecording(tabId);
fixture.frames.set(tabId, [top, {
tabId, frameId: 7, documentId: 'document-passport-late', url: 'https://passport.jd.com/new/login.aspx', accessible: true,
}]);
fixture.pages.set(`${tabId}:7`, rawSnapshot(tabId, [{
...recordingEvent(3),
traceId: 'trace-late-passport-login',
url: 'https://passport.jd.com/uc/loginService?aksParamsU=redacted',
}]));
fixture.listeners.committed({
tabId,
frameId: 7,
documentId: 'document-passport-late',
url: 'https://passport.jd.com/new/login.aspx',
timeStamp: Date.now(),
});
await vi.advanceTimersByTimeAsync(0);
const snapshot = await service.getTabBrowserRecording(tabId, 500, true);
expect(snapshot.events).toContainEqual(expect.objectContaining({
frameId: 7,
url: 'https://passport.jd.com/uc/loginService?aksParamsU=redacted',
}));
});
});
+187 -5
View File
@@ -9,6 +9,7 @@ import { ExtensionError } from '@/shared/errors';
import { inferBrowserTransformProfiles } from '@/features/browser-inference/inference';
import { normalizeBrowserRecordingCrypto } from '@/features/browser-crypto/model';
import { normalizeCallable } from '@/features/page-callable/service';
import { getFrameInventory } from '@/features/page-context/frames';
import { PAGE_RECORDER_PROTOCOL_VERSION, PAGE_RECORDER_REGISTRY_KEY } from './constants';
import {
buildRecordingLinks,
@@ -543,6 +544,7 @@ function normalizeEvent(value: unknown, allowSensitive: boolean): BrowserRecordi
parentEventId: optionalString(input.parentEventId, 160),
kind: input.kind as BrowserRecordingEvent['kind'],
source: input.source === 'browser' ? 'browser' : 'page',
frameId: Number.isSafeInteger(input.frameId) && Number(input.frameId) >= 0 ? Number(input.frameId) : undefined,
documentId: optionalString(input.documentId, 160),
operation: input.operation.slice(0, 160),
inputs: Array.isArray(input.inputs)
@@ -744,9 +746,11 @@ function snapshotFromEvents(
}
function snapshotFrom(target: BrowserTarget, raw: RawRecorderSnapshot): BrowserRecordingSnapshot {
const events = raw.events.map((event) => event.documentId || !target.documentId
? event
: { ...event, documentId: target.documentId });
const events = raw.events.map((event) => ({
...event,
frameId: target.frameId,
documentId: event.documentId || target.documentId,
}));
const callables = raw.callables
.map((item) => normalizeCallable(item, target))
.filter((item): item is BrowserPageCallable => Boolean(item));
@@ -792,6 +796,7 @@ function mergeSessionSnapshot(
globalSessionCount: previous?.status.globalSessionCount,
persistence: previous?.status.persistence,
persistenceError: previous?.status.persistenceError,
scope: previous?.status.scope || current.status.scope,
} : {}),
...status,
}, events, current.callables);
@@ -875,6 +880,7 @@ function applyNavigation(
parentEventId: existing?.parentEventId,
kind: 'navigation',
source: 'browser',
frameId: target.frameId,
documentId: navigation.previousDocumentId || existing?.documentId,
operation: navigationOperation(navigation),
label: navigationLabel(navigation),
@@ -904,6 +910,7 @@ export async function startBrowserRecording(
target: BrowserTarget,
input?: Partial<BrowserRecordingOptions>,
owner: OwnedRecording['owner'] = { kind: 'local' },
scope: BrowserRecordingStatus['scope'] = 'frame',
): Promise<BrowserRecordingSnapshot> {
if (expiredGrantOwner(owner)) throw new ExtensionError('grant_expired', '浏览器共享会话不存在或已经过期');
const options = normalizeOptions(input);
@@ -918,6 +925,7 @@ export async function startBrowserRecording(
if (!raw.startedAt) throw new ExtensionError('recorder_unavailable', '页面录制器尚未在目标文档就绪');
ownedRecordings.set(targetKey(target), { target, owner });
const snapshot = snapshotFrom(target, raw);
snapshot.status.scope = scope;
snapshot.status.isolationContextId = tab.isolationContextId;
snapshot.status.cookieStoreId = tab.cookieStoreId;
snapshot.status.pageUrl = await currentPageUrl(target);
@@ -980,6 +988,167 @@ export async function getBrowserRecording(target: BrowserTarget, limit = MAX_ENT
return recordingSnapshotForScope(snapshot, allowSensitive);
}
function emptyTabRecording(tabId: number): BrowserRecordingSnapshot {
return {
status: {
active: false,
scope: 'tab',
target: { tabId, frameId: 0 },
documentAvailable: true,
count: 0,
droppedCount: 0,
},
events: [],
traces: [],
links: [],
callables: [],
profileCandidates: [],
};
}
function localTabSnapshots(tabId: number): BrowserRecordingSnapshot[] {
return [...latestSnapshots.entries()].flatMap(([key, snapshot]) => {
if (snapshot.status.target.tabId !== tabId) return [];
const owner = ownedRecordings.get(key)?.owner || sessionOwners.get(key);
return owner?.kind === 'grant' ? [] : [snapshot];
});
}
async function tabFrameTargets(tabId: number): Promise<BrowserTarget[]> {
const frames = await getFrameInventory(tabId);
return frames
.filter((frame) => frame.accessible && /^https?:/i.test(frame.url))
.slice(0, RECORDING_MAX_SESSIONS)
.map(({ frameId, documentId }) => ({ tabId, frameId, documentId }));
}
function summedStatus(
snapshots: BrowserRecordingSnapshot[],
key: 'droppedCount' | 'budgetDroppedCount' | 'previewDroppedCount' | 'retainedBytes'
| 'retainedPreviewBytes' | 'retainedCallCount' | 'retainedCallBytes' | 'retainedCallDroppedCount',
): number {
return snapshots.reduce((total, snapshot) => total + (snapshot.status[key] || 0), 0);
}
function mergedPersistence(snapshots: BrowserRecordingSnapshot[]): BrowserRecordingStatus['persistence'] {
const states = snapshots.map((snapshot) => snapshot.status.persistence);
if (states.includes('degraded')) return 'degraded';
if (states.includes('memory-only')) return 'memory-only';
if (states.includes('pending')) return 'pending';
return states.includes('persisted') ? 'persisted' : undefined;
}
export function mergeTabRecordingSnapshots(
tabId: number,
snapshots: BrowserRecordingSnapshot[],
limit = MAX_ENTRIES,
): BrowserRecordingSnapshot {
if (!snapshots.length) return emptyTabRecording(tabId);
const primary = snapshots.find((snapshot) => snapshot.status.target.frameId === 0) || snapshots[0];
const byEventId = new Map<string, BrowserRecordingEvent>();
for (const snapshot of snapshots) {
for (const event of snapshot.events) {
byEventId.set(event.id, { ...event, frameId: event.frameId ?? snapshot.status.target.frameId });
}
}
const events = [...byEventId.values()]
.sort((left, right) => left.timestamp - right.timestamp || left.sequence - right.sequence || left.id.localeCompare(right.id))
.slice(-Math.max(1, Math.min(Math.floor(limit), MAX_ENTRIES)));
const eventIds = new Set(events.map((event) => event.id));
const links = buildRecordingLinks(events);
const callables = [...new Map(snapshots.flatMap((snapshot) => snapshot.callables).map((item) => [item.id, item])).values()];
const profileCandidates = [...new Map(snapshots.flatMap((snapshot) => snapshot.profileCandidates)
.filter((candidate) => eventIds.has(candidate.request.eventId) && candidate.sources.every((source) => eventIds.has(source.eventId)))
.map((candidate) => [candidate.id, candidate])).values()];
const startedAt = snapshots.reduce<number | undefined>((oldest, snapshot) => {
const next = snapshot.status.startedAt;
return next === undefined ? oldest : oldest === undefined ? next : Math.min(oldest, next);
}, undefined);
return {
status: {
...primary.status,
active: snapshots.some((snapshot) => snapshot.status.active),
scope: 'tab',
target: primary.status.target,
startedAt,
count: events.length,
droppedCount: summedStatus(snapshots, 'droppedCount'),
budgetDroppedCount: summedStatus(snapshots, 'budgetDroppedCount'),
previewDroppedCount: summedStatus(snapshots, 'previewDroppedCount'),
retainedBytes: summedStatus(snapshots, 'retainedBytes'),
retainedPreviewBytes: summedStatus(snapshots, 'retainedPreviewBytes'),
retainedCallCount: summedStatus(snapshots, 'retainedCallCount'),
retainedCallBytes: summedStatus(snapshots, 'retainedCallBytes'),
retainedCallDroppedCount: summedStatus(snapshots, 'retainedCallDroppedCount'),
globalRetainedBytes: Math.max(...snapshots.map((snapshot) => snapshot.status.globalRetainedBytes || 0)),
globalSessionCount: Math.max(...snapshots.map((snapshot) => snapshot.status.globalSessionCount || 0)),
persistence: mergedPersistence(snapshots),
persistenceError: snapshots.find((snapshot) => snapshot.status.persistenceError)?.status.persistenceError,
endedReason: snapshots.some((snapshot) => snapshot.status.active) ? undefined : primary.status.endedReason,
},
events,
links,
traces: buildRecordingTraces(events, links),
callables,
profileCandidates,
};
}
export async function startTabBrowserRecording(
tabId: number,
input?: Partial<BrowserRecordingOptions>,
): Promise<BrowserRecordingSnapshot> {
await ensureSessionsRestored();
await Promise.all(localTabSnapshots(tabId).map((snapshot) => clearBrowserRecording(snapshot.status.target)));
const targets = await tabFrameTargets(tabId);
const top = targets.find((target) => target.frameId === 0);
if (!top) throw new ExtensionError('target_unavailable', '标签页主文档当前不可录制');
const snapshots = [await startBrowserRecording(top, input, { kind: 'local' }, 'tab')];
const children = await Promise.allSettled(targets
.filter((target) => target.frameId !== 0)
.map((target) => startBrowserRecording(target, input, { kind: 'local' }, 'tab')));
snapshots.push(...children.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []));
return mergeTabRecordingSnapshots(tabId, snapshots);
}
export async function getTabBrowserRecording(
tabId: number,
limit = MAX_ENTRIES,
allowSensitive = false,
): Promise<BrowserRecordingSnapshot> {
await ensureSessionsRestored();
const stored = localTabSnapshots(tabId);
const snapshots = await Promise.all(stored.map(async (snapshot) => (
getBrowserRecording(snapshot.status.target, limit, allowSensitive).catch(() => recordingSnapshotForScope(snapshot, allowSensitive))
)));
return mergeTabRecordingSnapshots(tabId, snapshots, limit);
}
export async function tabBrowserRecordingStatus(tabId: number): Promise<BrowserRecordingStatus> {
return (await getTabBrowserRecording(tabId, MAX_ENTRIES, false)).status;
}
export async function stopTabBrowserRecording(
tabId: number,
allowSensitive = false,
): Promise<BrowserRecordingSnapshot> {
await ensureSessionsRestored();
const snapshots = await Promise.all(localTabSnapshots(tabId).map((snapshot) => (
stopBrowserRecording(snapshot.status.target, allowSensitive)
)));
return mergeTabRecordingSnapshots(tabId, snapshots);
}
export async function clearTabBrowserRecording(
tabId: number,
allowSensitive = false,
): Promise<BrowserRecordingSnapshot> {
await ensureSessionsRestored();
const snapshots = localTabSnapshots(tabId);
await Promise.all(snapshots.map((snapshot) => clearBrowserRecording(snapshot.status.target, allowSensitive)));
return emptyTabRecording(tabId);
}
export async function clearBrowserRecording(target: BrowserTarget, allowSensitive = false): Promise<BrowserRecordingSnapshot> {
const raw = normalizeRawSnapshot(await executeCommand(target, 'clear').catch(() => ({
version: PAGE_RECORDER_PROTOCOL_VERSION, active: false, count: 0, droppedCount: 0, events: [], callables: [],
@@ -1010,7 +1179,7 @@ export async function stopBrowserRecording(target: BrowserTarget, allowSensitive
export async function createRecordedPageCallable(
target: BrowserTarget,
input: { callHandleId: string; name: string },
input: { callHandleId: string; name: string; dynamicInputPaths?: string[] },
): Promise<BrowserPageCallable> {
const raw = await executeCommand(target, 'callable.create', input);
const callable = normalizeCallable(raw, target);
@@ -1151,9 +1320,22 @@ async function continueRecordingOnDocument(
documentId: details.documentId,
};
const key = targetKey(target);
const stored = await readSession(target);
let stored = await readSession(target);
if (!stored && details.frameId !== 0 && /^https?:/i.test(details.url)) {
const top = await readSession({ tabId: details.tabId, frameId: 0 });
const topKey = targetKey({ tabId: details.tabId, frameId: 0 });
const owner = ownedRecordings.get(topKey)?.owner || top?.owner;
if (top?.snapshot.status.active && top.snapshot.status.scope === 'tab' && owner?.kind !== 'grant') {
const snapshot = await startBrowserRecording(target, top.snapshot.status.options, { kind: 'local' }, 'tab');
notifyRecordingChanged(details.tabId, 'updated');
stored = { snapshot, owner: { kind: 'local' } };
}
}
if (!stored?.snapshot.status.active || !stored.snapshot.status.recordingId) return;
const previous = stored.snapshot;
if (!previous.status.navigation
&& previous.status.target.documentId === details.documentId
&& previous.status.pageUrl === details.url) return;
const previousNavigation = previous.status.navigation;
const hasTransitionEvidence = Boolean(details.transitionType || details.transitionQualifiers?.length);
const kind = hasTransitionEvidence
@@ -56,7 +56,7 @@ interface BrowserTransformWorkspaceProps {
export interface BrowserTransformSuggestionSeed {
revision: number;
candidate: BrowserProfileInferenceCandidate;
callable: BrowserPageCallable;
callables: BrowserPageCallable[];
profile: BrowserTransformProfile;
sampleBody?: string;
sampleLabel?: string;
@@ -232,6 +232,11 @@ function callableKindLabel(callable: BrowserPageCallable): string {
return '全局函数';
}
function profileDirectionLabel(profile: Pick<BrowserTransformProfileInput, 'request' | 'response'>): string {
if (profile.request.enabled && profile.response.enabled) return '双向协议网关';
return profile.request.enabled ? '仅请求转换' : '仅响应转换';
}
function referencesOf(node: BrowserTransformPipelineNode): BrowserTransformNodeReference[] {
if (node.kind === 'builtin') return node.inputs;
if (node.kind === 'page.call') return node.arguments;
@@ -516,8 +521,8 @@ export function BrowserTransformWorkspace({
update: (current) => ({
...current,
callables: [
...current.callables.filter((item) => item.id !== suggestion.callable.id),
suggestion.callable,
...current.callables.filter((item) => !suggestion.callables.some((callable) => callable.id === item.id)),
...suggestion.callables,
],
profiles: [
suggestion.profile,
@@ -801,7 +806,7 @@ export function BrowserTransformWorkspace({
<div>
<small>Agent · {pendingValidation.proofLevel === 'exact' ? '报文一致' : pendingValidation.proofLevel === 'structure' ? '结构一致' : '执行通过'}</small>
<strong>{pendingValidation.profile.name}</strong>
<p>{pendingValidation.profile.origin} · {pendingValidation.profile.request.enabled ? '请求加密' : '响应解密'} · {Math.max(1, Math.ceil((pendingValidation.expiresAt - Date.now()) / 60_000))} </p>
<p>{pendingValidation.profile.origin} · {profileDirectionLabel(pendingValidation.profile)} · {Math.max(1, Math.ceil((pendingValidation.expiresAt - Date.now()) / 60_000))} </p>
</div>
<div className="transform-validation-pending__actions">
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void resolvePendingValidation('discard')}></Button>
@@ -54,6 +54,9 @@ export function TransformProfileRail({
<header><div><strong></strong><span>{profiles.length}</span></div><Button size="icon" variant="ghost" aria-label="新建 Pipeline" title="新建 Pipeline" disabled={!tab} onClick={onCreate}><Plus size={15} /></Button></header>
<div className="transform-profile-list">
{profiles.map((profile) => {
const directionLabel = profile.request.enabled && profile.response.enabled
? '双向'
: profile.request.enabled ? '仅请求' : '仅响应';
const ready = (!profile.recovery || profile.recovery.state === 'ready')
&& originOf(tab?.url) === profile.origin && [profile.request, profile.response]
.flatMap((item) => item.enabled ? item.nodes : [])
@@ -61,7 +64,7 @@ export function TransformProfileRail({
.every((node) => callableIds.has(node.callableId));
return <button key={profile.id} className={selectedProfileId === profile.id ? 'is-selected' : ''} onClick={() => onSelect(profile)}>
<span className={`transform-profile-mark ${ready ? 'is-ready' : ''}`}><FileKey2 size={14} /></span>
<span><strong>{profile.name}</strong><small>{profile.match.methods.join(' / ') || 'ANY'} · {profile.match.urlPattern}</small></span>
<span><strong>{profile.name}</strong><small>{directionLabel} · {profile.match.methods.join(' / ') || 'ANY'} · {profile.match.urlPattern}</small></span>
<i title={ready ? '页面绑定可用' : '页面函数已失效'}>{ready ? <CheckCircle2 size={13} /> : <Unplug size={13} />}</i>
</button>;
})}
@@ -76,6 +76,38 @@ describe('guided browser transform compiler', () => {
}).inputPaths).toEqual(['body', 'body.options']);
});
it('decodes CryptoJS decrypt WordArray hex before writing the plaintext body', async () => {
const decryptCallable: BrowserPageCallable = {
...callable,
id: 'decrypt-aes',
operation: 'cryptojs.AES.decrypt',
crypto: {
adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric',
operation: 'AES.decrypt', algorithm: 'AES.decrypt', outputEncoding: 'hex',
},
output: { dataType: 'Object', encoding: 'hex', shape: 'value', paths: [] },
};
const direction = compileGuidedTransform(defaultGuidedTransform(decryptCallable), decryptCallable);
const result = await executeTransformDirection('profile-decrypt', 'response', direction, {
method: 'POST',
url: 'https://example.test/login',
headers: [],
bodyBase64: bodyBase64('cipher'),
}, async (callableId) => ({
callableId,
type: 'object',
preview: '7b226f6b223a747275657d',
value: '7b226f6b223a747275657d',
durationMs: 1,
}));
expect(decodeBody(result.bodyBase64)).toBe('{"ok":true}');
expect(parseGuidedTransform(direction, [decryptCallable])).toMatchObject({
callableId: decryptCallable.id,
outputKind: 'body',
});
});
it('compiles a form field and its content type without exposing DAG details', async () => {
const guide = {
...defaultGuidedTransform(callable, { outputKind: 'form-field', outputField: 'encryptedData' }),
+22 -3
View File
@@ -106,8 +106,21 @@ export function compileGuidedTransform(guide: GuidedTransformDraft, callable?: B
callableId: guide.callableId,
arguments: inputNodes.map((node) => ({ nodeId: node.id })),
};
const callReference = { nodeId: callId, path: guide.resultPath?.trim() || undefined };
const nodes: BrowserTransformPipelineNode[] = [...inputNodes, callNode];
let callReference = { nodeId: callId, path: guide.resultPath?.trim() || undefined };
if (callable?.crypto?.adapterId === 'cryptojs'
&& callable.crypto.operation.toLowerCase().includes('decrypt')
&& callable.output.encoding === 'hex') {
const decodeId = uid('decode');
nodes.push({
id: decodeId,
name: '还原 CryptoJS 解密字节',
kind: 'builtin',
operation: 'hex.decode',
inputs: [callReference],
});
callReference = { nodeId: decodeId, path: undefined };
}
const bodyFormat = envelopeBodyFormat(callable);
if (bodyFormat) {
@@ -243,8 +256,14 @@ export function parseGuidedTransform(
if (bodyOutputs.length !== 1) return undefined;
const output = bodyOutputs[0];
const resultPath = referenceFromCall(output.source.nodeId, output.source.path, call.id);
if (output.source.nodeId !== call.id) return undefined;
const decode = direction.nodes.find((node): node is Extract<BrowserTransformPipelineNode, { kind: 'builtin' }> => (
node.kind === 'builtin' && node.operation === 'hex.decode'
&& node.inputs.length === 1 && node.id === output.source.nodeId
));
const resultPath = decode
? referenceFromCall(decode.inputs[0].nodeId, decode.inputs[0].path, call.id)
: referenceFromCall(output.source.nodeId, output.source.path, call.id);
if (decode ? decode.inputs[0].nodeId !== call.id : output.source.nodeId !== call.id) return undefined;
if (output.destination === 'body') {
return { callableId: call.callableId, inputPaths, resultPath, outputKind: 'body', outputField: '', setFormContentType: false };
}
@@ -4,7 +4,8 @@ import type {
BrowserPageCallable,
BrowserProfileInferenceCandidate,
} from '@/types/models';
import { createBrowserTransformProfileInput } from './profile-draft';
import { createBrowserTransformProfileInput, pairedBrowserTransformCandidate } from './profile-draft';
import { executeTransformDirection } from './mapping';
const tab: ActiveTabInfo = {
id: 7,
@@ -66,6 +67,26 @@ const responseCandidate = {
} satisfies BrowserProfileInferenceCandidate;
describe('browser transform profile draft', () => {
it('reads only the captured form field for a single string input and rejects ambiguous fields', async () => {
const candidate = { ...responseCandidate, direction: 'request' as const,
request: { ...responseCandidate.request, bodyFormat: 'form' as const, serialization: 'form-field' as const } };
const packet = { method: 'POST', url: candidate.request.url,
headers: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded; charset=utf-8' }],
bodyBase64: btoa('encryptedData={"username":"admin","password":"admin123"}') };
const profile = createBrowserTransformProfileInput(tab, undefined, callable, candidate, packet);
expect(profile.request.nodes.filter((node) => node.kind === 'context.read')).toMatchObject([{ path: 'body.encryptedData' }]);
let received: unknown[] = [];
await executeTransformDirection('test', 'request', profile.request, packet, async (callableId, args) => {
received = args;
return { callableId, type: 'string', preview: 'cipher', value: 'cipher', durationMs: 1 };
});
expect(received).toEqual(['{"username":"admin","password":"admin123"}']);
for (const body of ['username=admin', 'encryptedData=a&encryptedData=b']) {
expect(() => createBrowserTransformProfileInput(tab, undefined, callable, candidate, { ...packet, bodyBase64: btoa(body) })).toThrow(/input_paths/);
}
const jsonPacket = { ...packet, headers: [{ name: 'Content-Type', value: 'application/json' }], bodyBase64: btoa('{"username":"admin"}') };
expect(createBrowserTransformProfileInput(tab, undefined, callable, candidate, jsonPacket).request.nodes[0]).toMatchObject({ path: 'body' });
});
it('compiles an inferred response decryptor into the response direction', () => {
const profile = createBrowserTransformProfileInput(tab, undefined, callable, responseCandidate);
@@ -77,7 +98,106 @@ describe('browser transform profile draft', () => {
expect.objectContaining({ kind: 'output.write', destination: 'body' }),
]));
expect(profile.match).toEqual({ methods: ['GET'], urlPattern: '*/api/profile' });
expect(profile.name).toContain('响应明文网关');
expect(profile.name).toContain('浏览器协议网关');
});
it('maps response ciphertext, key, and iv into one decrypt callable', async () => {
const dynamicCallable: BrowserPageCallable = {
...callable,
inputSlots: [
callable.inputSlots[0],
{ id: 'key', name: 'key', index: 1, role: 'key', dataType: 'string', required: true, retained: false },
{ id: 'iv', name: 'iv', index: 2, role: 'iv', dataType: 'string', required: true, retained: false },
],
};
const dynamicCandidate: BrowserProfileInferenceCandidate = {
...responseCandidate,
request: {
...responseCandidate.request,
mappings: [
{ sourceEventId: 'decrypt-event', destination: 'body.message', serialization: 'json-field' },
{ sourceEventId: 'decrypt-event', destination: 'body.key', serialization: 'json-field' },
{ sourceEventId: 'decrypt-event', destination: 'body.iv', serialization: 'json-field' },
],
},
};
const profile = createBrowserTransformProfileInput(tab, undefined, dynamicCallable, dynamicCandidate);
const packet = {
method: 'POST', url: dynamicCandidate.request.url,
headers: [{ name: 'Content-Type', value: 'application/json' }],
bodyBase64: btoa(JSON.stringify({ message: 'cipher', key: '0011', iv: 'aabb' })),
};
let received: unknown[] = [];
await executeTransformDirection('test', 'response', profile.response, packet, async (callableId, args) => {
received = args;
return { callableId, type: 'string', preview: 'plain', value: 'plain', durationMs: 1 };
});
expect(received).toEqual(['cipher', '0011', 'aabb']);
});
it('pairs one browser transaction and compiles both directions into one profile', () => {
const requestCandidate: BrowserProfileInferenceCandidate = {
...responseCandidate,
id: 'candidate-request',
transactionId: 'fetch-1',
direction: 'request',
request: { ...responseCandidate.request, eventId: 'request-event', method: 'POST' },
};
const pairedResponse: BrowserProfileInferenceCandidate = {
...responseCandidate,
transactionId: 'fetch-1',
request: { ...responseCandidate.request, method: 'POST' },
};
const encryptCallable: BrowserPageCallable = {
...callable,
id: 'encrypt-callable',
name: '页面 AES 加密',
operation: 'AES.encrypt',
provenance: { eventId: 'encrypt-event' },
};
expect(pairedBrowserTransformCandidate([requestCandidate, pairedResponse], requestCandidate)?.id)
.toBe(pairedResponse.id);
const profile = createBrowserTransformProfileInput(
tab,
undefined,
encryptCallable,
requestCandidate,
undefined,
{ candidate: pairedResponse, callable },
);
expect(profile.request.enabled).toBe(true);
expect(profile.response.enabled).toBe(true);
expect(profile.request.nodes).toContainEqual(expect.objectContaining({ kind: 'page.call', callableId: encryptCallable.id }));
expect(profile.response.nodes).toContainEqual(expect.objectContaining({ kind: 'page.call', callableId: callable.id }));
expect(profile.name).toBe('POST */api/profile 浏览器协议网关');
});
it('does not guess when one trace contains multiple opposite candidates for the same route', () => {
const requestCandidate = { ...responseCandidate, id: 'request', direction: 'request' as const };
const responseA = { ...responseCandidate, id: 'response-a' };
const responseB = { ...responseCandidate, id: 'response-b' };
expect(pairedBrowserTransformCandidate([requestCandidate, responseA, responseB], requestCandidate)).toBeUndefined();
expect(() => pairedBrowserTransformCandidate([requestCandidate, responseA, responseB], requestCandidate, true))
.toThrow('尚未保存单向网关');
});
it('does not pair candidates from different recording sessions', () => {
const requestCandidate = { ...responseCandidate, id: 'request', transactionId: 'fetch-1', direction: 'request' as const };
const staleResponse = { ...responseCandidate, id: 'stale-response', transactionId: 'fetch-1', recordingId: 'recording-old' };
expect(pairedBrowserTransformCandidate([requestCandidate, staleResponse], requestCandidate)).toBeUndefined();
});
it('does not fall back to the route when transaction IDs disagree', () => {
const requestCandidate = { ...responseCandidate, id: 'request', transactionId: 'fetch-1', direction: 'request' as const };
const otherResponse = { ...responseCandidate, id: 'other-response', transactionId: 'fetch-2' };
expect(pairedBrowserTransformCandidate([requestCandidate, otherResponse], requestCandidate)).toBeUndefined();
});
it('serializes request-transaction profiles because they mutate one browser session', () => {
@@ -5,7 +5,9 @@ import type {
BrowserRecordingEvent,
BrowserTransformDirection,
BrowserTransformProfileInput,
BrowserTransformPacket,
} from '@/types/models';
import { ExtensionError } from '@/shared/errors';
import { compileGuidedTransform, defaultGuidedTransform, type GuidedTransformOutputKind } from './guided';
interface RequestRouteSource {
@@ -13,6 +15,12 @@ interface RequestRouteSource {
method?: string;
}
export interface BrowserTransformProfileBinding {
candidate: BrowserProfileInferenceCandidate;
callable: BrowserPageCallable;
packet?: BrowserTransformPacket;
}
function originOf(url?: string): string {
try { return url ? new URL(url).origin : ''; } catch { return ''; }
}
@@ -26,7 +34,33 @@ function emptyDirection(enabled = false): BrowserTransformDirection {
return { enabled, nodes: [] };
}
function candidateGuidance(candidate?: BrowserProfileInferenceCandidate): {
function sameTarget(left: BrowserProfileInferenceCandidate, right: BrowserProfileInferenceCandidate): boolean {
return left.target.tabId === right.target.tabId
&& left.target.frameId === right.target.frameId
&& (!left.target.documentId || !right.target.documentId || left.target.documentId === right.target.documentId);
}
export function pairedBrowserTransformCandidate(
candidates: BrowserProfileInferenceCandidate[],
candidate: BrowserProfileInferenceCandidate,
requireUnambiguous = false,
): BrowserProfileInferenceCandidate | undefined {
const opposite = candidates.filter((item) => item.id !== candidate.id
&& item.recordingId === candidate.recordingId
&& item.direction !== candidate.direction && sameTarget(item, candidate));
const matches = candidate.transactionId
? opposite.filter((item) => item.transactionId === candidate.transactionId)
: opposite.filter((item) => !item.transactionId
&& item.traceId === candidate.traceId
&& item.request.method.toUpperCase() === candidate.request.method.toUpperCase()
&& item.request.url === candidate.request.url);
if (requireUnambiguous && matches.length > 1) {
throw new ExtensionError('profile_evidence_ambiguous', `同一事务存在 ${matches.length} 个反方向候选,无法完整合并;尚未保存单向网关`);
}
return matches.length === 1 ? matches[0] : undefined;
}
function candidateGuidance(candidate?: BrowserProfileInferenceCandidate, callable?: BrowserPageCallable, packet?: BrowserTransformPacket): {
inputPaths?: string[];
outputKind?: GuidedTransformOutputKind;
outputField?: string;
@@ -35,9 +69,30 @@ function candidateGuidance(candidate?: BrowserProfileInferenceCandidate): {
const serialization = candidate?.request.serialization;
if (!destination) return {};
if (candidate?.direction === 'response') {
return { inputPaths: [destination], outputKind: 'body' };
return {
inputPaths: candidate.request.mappings
.map((mapping) => mapping.destination)
.filter((path): path is string => Boolean(path)),
outputKind: 'body',
};
}
if (serialization === 'form-field') {
let inputPaths: string[] | undefined;
const slots = callable?.inputSlots.filter((slot) => !slot.retained);
if (packet && slots?.length === 1 && slots[0].dataType === 'string') {
const contentType = packet.headers.find((header) => header.name.toLowerCase() === 'content-type')?.value.split(';')[0].trim().toLowerCase();
if (contentType === 'application/x-www-form-urlencoded') {
const body = new TextDecoder().decode(Uint8Array.from(atob(packet.bodyBase64), (char) => char.charCodeAt(0)));
const fields = new URLSearchParams(body);
const destinations = new Set(candidate?.request.mappings.map((mapping) => mapping.destination));
if (destinations.size > 1 || fields.getAll(destination.slice(5)).length !== 1) {
throw new ExtensionError('profile_input_mismatch', '无法唯一确定表单明文输入,请显式指定 input_paths;尚未发送请求');
}
inputPaths = [destination];
}
}
return { inputPaths, outputKind: 'form-field', outputField: destination.slice(5) };
}
if (serialization === 'form-field') return { outputKind: 'form-field', outputField: destination.slice(5) };
if (serialization === 'json-field') return { outputKind: 'json-field', outputField: destination.slice(5) };
if (serialization === 'header') return { outputKind: 'header', outputField: destination.slice(7) };
if (serialization === 'query') return { outputKind: 'query', outputField: destination.slice(6) };
@@ -49,17 +104,19 @@ export function createBrowserTransformProfileInput(
event?: BrowserRecordingEvent,
callable?: BrowserPageCallable,
candidate?: BrowserProfileInferenceCandidate,
packet?: BrowserTransformPacket,
paired?: BrowserTransformProfileBinding,
): BrowserTransformProfileInput {
const guide = defaultGuidedTransform(callable, candidateGuidance(candidate));
const guide = defaultGuidedTransform(callable, candidateGuidance(candidate, callable, packet));
const compiled = callable ? compileGuidedTransform(guide, callable) : emptyDirection(true);
const responseDirection = candidate?.direction === 'response';
const routeEvent = candidate ? {
url: candidate.request.url,
method: candidate.request.method,
} : event;
return {
const profile: BrowserTransformProfileInput = {
name: routeEvent?.url
? `${routeEvent.method || 'HTTP'} ${routeOf(routeEvent, tab)} ${responseDirection ? '响应' : '请求'}明文网关`
? `${routeEvent.method || 'HTTP'} ${routeOf(routeEvent, tab)} 浏览器协议网关`
: `${tab.title || '当前页面'} 明文网关`,
enabled: true,
target: { tabId: tab.id, frameId: 0 },
@@ -70,4 +127,16 @@ export function createBrowserTransformProfileInput(
failMode: 'closed',
maxConcurrency: callable?.kind === 'request-transaction' ? 1 : 2,
};
if (!paired) return profile;
if (!candidate
|| pairedBrowserTransformCandidate([candidate, paired.candidate], candidate)?.id !== paired.candidate.id) {
throw new ExtensionError('profile_evidence_mismatch', '请求与响应候选不属于同一个浏览器协议网关');
}
const pairedGuide = defaultGuidedTransform(
paired.callable,
candidateGuidance(paired.candidate, paired.callable, paired.packet),
);
profile[paired.candidate.direction] = compileGuidedTransform(pairedGuide, paired.callable);
if (paired.callable.kind === 'request-transaction') profile.maxConcurrency = 1;
return profile;
}
+11 -6
View File
@@ -855,9 +855,10 @@ export async function executeBrowserTransform(input: BrowserTransformExecuteInpu
direction,
input.packet,
);
return input.direction === 'request'
const result = input.direction === 'request'
? await bindOnlineTransactionSession(profile, execution)
: execution;
return { ...result, explanation: profile.explanation };
} finally {
leave();
}
@@ -866,6 +867,7 @@ export async function executeBrowserTransform(input: BrowserTransformExecuteInpu
export async function validateBrowserTransformProfile(
input: BrowserTransformProfileInput,
packet: BrowserTransformExecuteInput['packet'],
options: { direction?: BrowserTransformDirectionName; profileId?: string } = {},
): Promise<{ profile: BrowserTransformProfile; execution: BrowserTransformExecution }> {
const target = await resolveDocumentTarget(input.target);
const isolation = await currentTransformIsolation(target);
@@ -878,19 +880,22 @@ export async function validateBrowserTransformProfile(
const normalized = withRequestTransactionBinding(
normalizeProfile({
...input,
id: `validation-${crypto.randomUUID()}`,
id: options.profileId || `validation-${crypto.randomUUID()}`,
target,
maxConcurrency: transactionSafeConcurrency(input, callables),
}, isolation),
requestTransaction,
);
const profile = withTransformExplanation(normalized, callables);
const directionName: BrowserTransformDirectionName = profile.request.enabled
const directionName: BrowserTransformDirectionName = options.direction || (profile.request.enabled
? 'request'
: profile.response.enabled ? 'response' : 'request';
: profile.response.enabled ? 'response' : 'request');
const direction = profile[directionName];
if (!profile.enabled || !direction.enabled) {
throw new ExtensionError('transform_direction_disabled', '候选明文网关没有启用任何转换方向');
if (!profile.enabled) {
throw new ExtensionError('transform_profile_disabled', '候选明文网关未启用');
}
if (!direction.enabled) {
throw new ExtensionError('transform_direction_disabled', `候选明文网关未启用 ${directionName} 转换方向`);
}
assertTransformRoute(profile.match.methods, profile.match.urlPattern, packet, profile.origin);
assertRequestTransactionPacket(profile, packet);
@@ -8,16 +8,17 @@ import { errorMessage, request } from '@/platform/messaging/runtime';
import type {
ActiveTabInfo, BrowserDeepCaptureFrame, BrowserDeepCaptureMatcher, BrowserDeepCaptureStatus,
BrowserPageCallable, BrowserPageCallableExecution,
BrowserProfileInferenceCandidate, BrowserRecordingEvent,
BrowserProfileInferenceCandidate, BrowserRecordingEvent, BrowserTarget,
} from '@/types/models';
import './deep-capture-workspace.css';
import { cryptoDeepCaptureMatcher } from '@/features/browser-crypto/model';
import { eventMatcher } from './matcher';
import { capturedCallableSample, type CapturedCallableSample } from './callable-sample';
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
interface DeepCaptureWorkspaceProps {
tab?: ActiveTabInfo;
recordingTarget?: BrowserTarget;
selectedEvent?: BrowserRecordingEvent;
selectedCandidate?: BrowserProfileInferenceCandidate;
autoArmRequest?: number;
@@ -43,32 +44,6 @@ const STATUS_LABELS: Record<BrowserDeepCaptureStatus['state'], string> = {
error: '需要处理',
};
function eventMatcher(
event?: BrowserRecordingEvent,
candidate?: BrowserProfileInferenceCandidate,
): BrowserDeepCaptureMatcher | undefined {
if (!event) return undefined;
const frameHints = candidate?.capturePlan?.matcherEventId === event.id
? candidate.capturePlan.frameHints
: undefined;
const crypto = cryptoDeepCaptureMatcher(event);
if (crypto) return { ...crypto, frameHints };
if (['fetch', 'xhr', 'form'].includes(event.kind) && event.url) {
return { kind: 'request', urlPattern: event.url, frameHints };
}
if (['beacon', 'worker', 'message'].includes(event.kind) && event.wrapperHandleId) {
return {
kind: 'boundary',
eventKind: event.kind as 'beacon' | 'worker' | 'message',
operation: event.operation,
wrapperHandleId: event.wrapperHandleId,
scriptUrl: event.scriptUrl,
frameHints,
};
}
return undefined;
}
function compactUrl(value: string): string {
if (!value) return '内联脚本';
try {
@@ -102,6 +77,7 @@ const FRAME_SOURCE_LABELS: Record<BrowserDeepCaptureFrame['sourceKind'], string>
export function DeepCaptureWorkspace({
tab,
recordingTarget,
selectedEvent,
selectedCandidate,
autoArmRequest = 0,
@@ -141,7 +117,13 @@ export function DeepCaptureWorkspace({
const handledAutoCapturePause = useRef(0);
const automaticFlowRequested = useRef(false);
const target = status?.target || (tab ? { tabId: tab.id, frameId: 0 } : undefined);
const baseTarget = useMemo(() => recordingTarget
? { ...recordingTarget }
: tab ? { tabId: tab.id, frameId: 0 } : undefined,
[recordingTarget?.documentId, recordingTarget?.frameId, recordingTarget?.tabId, tab?.id]);
const target = status && baseTarget
&& status.target.tabId === baseTarget.tabId && status.target.frameId === baseTarget.frameId
? status.target : baseTarget;
const paused = status?.state === 'paused' && Boolean(status.pause);
useEffect(() => { statusRef.current = status; }, [status]);
@@ -177,15 +159,15 @@ export function DeepCaptureWorkspace({
return;
}
try {
const nextStatus = await request('deep.capture.status', { tabId: tab.id, frameId: 0 });
const nextStatus = await request('deep.capture.status', baseTarget!);
setStatus(nextStatus);
const nextCallables = await request('callable.list', { tabId: tab.id, frameId: 0 }).catch(() => []);
const nextCallables = await request('callable.list', baseTarget!).catch(() => []);
setCallables(nextCallables);
setLoadError('');
} catch (error) {
setLoadError(errorMessage(error));
}
}, [tab]);
}, [baseTarget?.documentId, baseTarget?.frameId, baseTarget?.tabId, tab]);
useEffect(() => { void load(); }, [load]);
@@ -209,11 +191,11 @@ export function DeepCaptureWorkspace({
}
void run(async () => {
setExecution(undefined);
const next = await request('deep.capture.start', { tabId: tab.id, frameId: 0, matcher: suggestedMatcher });
const next = await request('deep.capture.start', { ...baseTarget!, matcher: suggestedMatcher });
automaticFlowRequested.current = true;
setStatus(next);
}, '自动分析已武装,请在目标页面重复刚才的操作');
}, [autoArmRequest, busy, run, status, suggestedMatcher, tab]);
}, [autoArmRequest, baseTarget, busy, run, status, suggestedMatcher, tab]);
useEffect(() => {
if (!autoRecoveryRequest || handledAutoRecoveryRequest.current >= autoRecoveryRequest
@@ -234,10 +216,10 @@ export function DeepCaptureWorkspace({
useEffect(() => {
if (!tab || !['armed', 'paused', 'attached'].includes(status?.state || '')) return undefined;
const interval = window.setInterval(() => void request('deep.capture.status', { tabId: tab.id, frameId: 0 })
const interval = window.setInterval(() => void request('deep.capture.status', baseTarget!)
.then(setStatus).catch((error) => setLoadError(errorMessage(error))), status?.state === 'armed' ? 450 : 1_200);
return () => window.clearInterval(interval);
}, [status?.state, tab]);
}, [baseTarget, status?.state, tab]);
useEffect(() => {
if (!paused || !target) return undefined;
@@ -299,7 +281,7 @@ export function DeepCaptureWorkspace({
: { kind: 'request', urlPattern: urlPattern.trim(), frameHints };
setExecution(undefined);
automaticFlowRequested.current = false;
setStatus(await request('deep.capture.start', { tabId: tab.id, frameId: 0, matcher }));
setStatus(await request('deep.capture.start', { ...baseTarget!, matcher }));
}, '深度捕获已武装,请在目标页面重现一次操作');
const resume = () => run(async () => {
@@ -416,8 +398,8 @@ export function DeepCaptureWorkspace({
}, recoveryProfileId
? '新页面函数已捕获,旧网关继续停用;请完成本地回放验证'
: captureStrategy === 'request-transaction'
? '页面请求事务与明文网关已自动保存,真实发送将在回放时被截获'
: '完整业务加密流程与明文网关已自动保存');
? '页面请求事务已捕获;存在响应方向时将继续完成同一个协议网关'
: '完整业务转换流程已捕获;存在配对方向时将继续完成同一个协议网关');
}, [
onRecoveryCaptured,
onUseRecommendedCallable,
@@ -433,20 +415,23 @@ export function DeepCaptureWorkspace({
const useRecordedRecommendation = () => run(async () => {
if (!target || !recordedRecommendation?.source.callHandleId) throw new Error('推荐调用已经失效');
setStatus(await request('deep.capture.resume', target));
let callable = callables.find((item) => item.provenance.eventId === recordedRecommendation.source.eventId);
const inputCount = recordedRecommendation.source.dynamicInputPaths?.length || 1;
let callable = callables.find((item) => item.provenance.eventId === recordedRecommendation.source.eventId
&& item.inputSlots.filter((slot) => !slot.retained).length === inputCount);
if (!callable) {
callable = await request('callable.create', {
...target,
source: 'recording',
callHandleId: recordedRecommendation.source.callHandleId,
name: `${recordedRecommendation.source.crypto?.algorithm || recordedRecommendation.source.crypto?.operation || recordedRecommendation.source.operation} 页面函数`,
dynamicInputPaths: recordedRecommendation.source.dynamicInputPaths,
});
}
const selected = callable;
setCallables((current) => [...current.filter((item) => item.id !== selected.id), selected]);
setSelectedCallableId(selected.id);
await onUseRecommendedCallable?.(recordedRecommendation, selected);
}, '已使用录制调用生成并保存明文网关');
}, '当前转换方向已完成;存在配对方向时将继续合并');
const executeCallable = () => run(async () => {
if (!target || !selectedCallableId) throw new Error('请选择页面函数');
@@ -605,7 +590,7 @@ export function DeepCaptureWorkspace({
<header><Braces size={14} /><strong></strong></header>
<div className="deep-frame-summary"><strong>{selectedFrame?.functionName || '未选择调用帧'}</strong><small>{selectedFrame ? `${compactUrl(selectedFrame.url)}:${selectedFrame.lineNumber}:${selectedFrame.columnNumber}` : ''}</small>{selectedFrame?.sourceMapUrl && <small title={selectedFrame.sourceMapUrl}>Source Map · {compactUrl(selectedFrame.sourceMapUrl)}</small>}<span>{selectedFrame?.thisPreview || ''}</span></div>
{selectedFrame?.sourceKind === 'extension-hook' ? <div className="deep-function-assessment is-hook"><Bug size={15} /><span><strong></strong><small></small></span></div> : selectedFrame?.functionInspection?.resolved ? <div className={`deep-function-assessment ${selectedFrame.functionInspection.riskFlags.length ? 'has-risk' : 'is-clean'}`}>
{selectedFrame.functionInspection.riskFlags.length ? <><ShieldAlert size={15} /><span><strong></strong><small>{selectedFrame.functionInspection.riskFlags.map((risk) => RISK_LABELS[risk]).join(' · ')}</small></span></> : <><Check size={15} /><span><strong></strong><small>{selectedFrame.functionInspection.parameterCount || 0} · {selectedFrame.functionInspection.resolution === 'receiver-method' ? '页面方法' : selectedFrame.functionInspection.resolution === 'scope-binding' ? '闭包绑定' : '当前栈帧'} · </small></span></>}
{selectedFrame.functionInspection.riskFlags.length ? <><ShieldAlert size={15} /><span><strong></strong><small>{selectedFrame.functionInspection.riskFlags.map((risk) => RISK_LABELS[risk]).join(' · ')}</small></span></> : <><Check size={15} /><span><strong></strong><small>{selectedFrame.functionInspection.parameterCount || 0} · {selectedFrame.functionInspection.resolution === 'receiver-method' ? '页面方法' : selectedFrame.functionInspection.resolution === 'scope-binding' ? '闭包绑定' : selectedFrame.functionInspection.resolution === 'current-function' ? '当前函数' : '当前栈帧'} · </small></span></>}
</div> : <div className="deep-function-assessment has-risk"><AlertTriangle size={15} /><span><strong></strong><small>{selectedFrame?.functionInspection?.candidateCount ? `发现 ${selectedFrame.functionInspection.candidateCount} 个同分候选;` : ''}</small></span></div>}
<div className="deep-adapter-editor__primary">{recoveryProfileId
? <Button variant="primary" disabled={busy || !recoverySelectionReady} onClick={() => void captureRecovery(recoveryCaptureStrategy)}><RotateCcw size={14} />{recoveryCaptureStrategy === 'request-transaction' ? '按所选函数恢复请求事务' : '用所选函数恢复绑定'}</Button>
@@ -21,7 +21,12 @@ function comparableUrl(value?: string): string {
}
function nameMatches(frameName: string, hintName: string): boolean {
return frameName === hintName || frameName.endsWith(`.${hintName}`) || hintName.endsWith(`.${frameName}`);
const normalize = (value: string) => /^(?:\(anonymous\)|<anonymous>|anonymous)$/i.test(value.trim())
? '(anonymous)'
: value;
const frame = normalize(frameName);
const hint = normalize(hintName);
return frame === hint || frame.endsWith(`.${hint}`) || hint.endsWith(`.${frame}`);
}
function matchingHint(frame: BrowserDeepCaptureFrame, hints: BrowserBusinessFrameHint[]): BrowserBusinessFrameHint | undefined {
@@ -30,10 +35,12 @@ function matchingHint(frame: BrowserDeepCaptureFrame, hints: BrowserBusinessFram
}
function isEventHandler(frame: BrowserDeepCaptureFrame): boolean {
if (frame.functionInspection?.resolution === 'event-listener') return true;
if (EVENT_HANDLER_NAME.test(frame.functionName)) return true;
const parameters = frame.functionInspection?.parameterNames || [];
return parameters.some((name) => /^(?:event|evt)$/i.test(name))
&& /(?:Element|Document|Window)/.test(frame.thisPreview);
return parameters.some((name) => /^(?:e|event|evt)$/i.test(name))
&& (/(?:Element|Document|Window|#[A-Za-z_$][\w$-]*)/.test(frame.thisPreview)
|| frame.scopes.some((scope) => scope.variables.some((variable) => /Event\b/.test(variable.preview))));
}
function hintedFrameOrder(
@@ -121,9 +128,13 @@ export function rankBusinessFrames(
.filter((frame) => frame.sourceKind === 'page' && frame.functionInspection?.resolved && matchingHint(frame, hints))
.sort((left, right) => hintedFrameOrder(left, right, hints));
const closestHinted = resolvedHinted[0];
const closestRisks = closestHinted?.functionInspection?.riskFlags || [];
const transactionRequired = Boolean(closestHinted
&& (isEventHandler(closestHinted) || closestRisks.some((risk) => TRANSACTION_RISKS.has(risk))));
const unhintedEventHandler = hints.length ? undefined : ranked.find((frame) => (
frame.sourceKind === 'page' && frame.functionInspection?.resolved && isEventHandler(frame)
));
const transactionFrame = closestHinted || unhintedEventHandler;
const closestRisks = transactionFrame?.functionInspection?.riskFlags || [];
const transactionRequired = Boolean(transactionFrame
&& (isEventHandler(transactionFrame) || closestRisks.some((risk) => TRANSACTION_RISKS.has(risk))));
const transactionBlocked = Boolean(transactionRequired && closestRisks.includes('storage'));
const eligible = ordered.filter((frame) => frame.functionInspection?.resolved
&& !frame.functionInspection.riskFlags.length && !isEventHandler(frame));
@@ -133,19 +144,19 @@ export function rankBusinessFrames(
const automatic = automaticEligible[0];
const alternative = automaticEligible[1];
let automaticCapture: RankedBusinessFrames['automaticCapture'];
if (transactionRequired && !transactionBlocked && closestHinted) {
if (transactionRequired && !transactionBlocked && transactionFrame) {
automaticCapture = {
state: 'ready',
strategy: 'request-transaction',
frameId: closestHinted.id,
reason: isEventHandler(closestHinted)
frameId: transactionFrame.id,
reason: isEventHandler(transactionFrame)
? '共同业务入口是页面事件处理器,将在隔离事务中截获并取消真实请求'
: '共同业务函数直接读取页面或发送请求,将以隔离事务保留完整动态参数关系',
};
} else if (transactionBlocked && closestHinted) {
} else if (transactionBlocked && transactionFrame) {
automaticCapture = {
state: 'blocked',
frameId: closestHinted.id,
frameId: transactionFrame.id,
reason: '共同业务函数会访问页面存储;当前事务回滚无法证明存储副作用已完全隔离',
};
} else if (automatic && alternative && (automatic.businessScore || 0) - (alternative.businessScore || 0) < 8) {
+15
View File
@@ -0,0 +1,15 @@
import type { BrowserDeepCaptureMatcher, BrowserProfileInferenceCandidate, BrowserRecordingEvent } from '@/types/models';
import { cryptoDeepCaptureMatcher } from '@/features/browser-crypto/model';
export function eventMatcher(event?: BrowserRecordingEvent, candidate?: BrowserProfileInferenceCandidate): BrowserDeepCaptureMatcher | undefined {
if (!event) return undefined;
const frameHints = candidate?.capturePlan?.frameHints;
const crypto = cryptoDeepCaptureMatcher(event);
if (crypto) return { ...crypto, frameHints };
if (['fetch', 'xhr', 'form'].includes(event.kind) && event.url) return { kind: 'request', urlPattern: event.url, frameHints };
if (['beacon', 'worker', 'message'].includes(event.kind) && event.wrapperHandleId) return {
kind: 'boundary', eventKind: event.kind as 'beacon' | 'worker' | 'message', operation: event.operation,
wrapperHandleId: event.wrapperHandleId, scriptUrl: event.scriptUrl, frameHints,
};
return undefined;
}
+76 -2
View File
@@ -9,6 +9,7 @@ const attachedTabs = new Set<number>();
const knownTabs = new Set<number>();
let failResume = false;
let failDetach = false;
let commandHandler: ((method: string, params?: Record<string, unknown>) => unknown) | undefined;
const STORAGE_KEY = 'session.deep-capture.v1';
@@ -27,9 +28,9 @@ const debuggerApi = {
getTargets: vi.fn(async () => [...knownTabs].map((tabId) => ({
attached: attachedTabs.has(tabId), tabId, id: `target-${tabId}`, type: 'page', url: 'https://example.test/',
}))),
sendCommand: vi.fn(async (_target: { tabId?: number; sessionId?: string }, method: string) => {
sendCommand: vi.fn(async (_target: { tabId?: number; sessionId?: string }, method: string, params?: Record<string, unknown>) => {
if (method === 'Debugger.resume' && failResume) throw new Error('fixture resume failed');
return {};
return commandHandler?.(method, params) || {};
}),
onEvent: { addListener: vi.fn((listener: typeof eventListeners[number]) => eventListeners.push(listener)) },
onDetach: { addListener: vi.fn((listener: (source: { tabId?: number; sessionId?: string }, reason: string) => void) => detachListeners.push(listener)) },
@@ -125,6 +126,7 @@ describe('deep capture debugger lifecycle', () => {
knownTabs.clear();
failResume = false;
failDetach = false;
commandHandler = undefined;
vi.clearAllMocks();
});
@@ -162,6 +164,17 @@ describe('deep capture debugger lifecycle', () => {
expect(debuggerApi.attach).not.toHaveBeenCalled();
});
it('allows Agent capture after local release but never takes over an active local session', async () => {
const target = { tabId: 39, frameId: 0 };
const matcher = { kind: 'request' as const, urlPattern: '/login' };
const owner = { kind: 'grant' as const, grantId: 'agent', expiresAt: Date.now() + 60_000 };
await startDeepCapture(target, matcher);
await expect(startDeepCapture(target, matcher, owner)).rejects.toMatchObject({ code: 'permission_denied' });
await resumeDeepCapture(target);
await expect(startDeepCapture(target, matcher, owner)).resolves.toMatchObject({ state: 'armed' });
await resumeDeepCapture(target, 'agent-done', owner);
});
it('restores a paused page when callable capture validation fails', async () => {
const target = { tabId: 20, frameId: 0 };
seedStatus(target.tabId, pausedStatus(target.tabId, {
@@ -322,6 +335,67 @@ describe('deep capture debugger lifecycle', () => {
await resumeDeepCapture(target);
});
it('resolves an anonymous form handler through its paused SubmitEvent', async () => {
const target = { tabId: 30, frameId: 0 };
commandHandler = (method, params) => {
if (method === 'Runtime.getProperties' && params?.objectId === 'local-scope') {
return { result: [{ name: 'e', value: { type: 'object', description: 'SubmitEvent' } }] };
}
if (method === 'Runtime.getProperties' && params?.objectId === 'listener-1') {
return { internalProperties: [{
name: '[[FunctionLocation]]',
value: { type: 'object', value: { scriptId: 'page-script', lineNumber: 160 } },
}] };
}
if (method === 'Debugger.evaluateOnCallFrame') {
const expression = String(params?.expression || '');
return expression.includes('arguments.callee')
? { result: { type: 'function', objectId: 'listener-1' } }
: { result: { type: 'undefined' } };
}
if (method === 'Runtime.callFunctionOn' && params?.objectId === 'listener-1') {
return { result: { value: {
functionName: '', parameterCount: 1, parameterNames: ['e'], riskFlags: ['network', 'dom'],
} } };
}
return {};
};
await startDeepCapture(target, {
kind: 'request',
urlPattern: '/crypto/sqli/aes-ecb/encrypt/login',
frameHints: [{
functionName: '<anonymous>', url: 'https://example.test/login', support: 1, averageDepth: 1,
}],
});
for (const listener of eventListeners) listener({ tabId: target.tabId }, 'Debugger.paused', {
reason: 'XHR',
callFrames: [{
callFrameId: 'hook-frame', functionName: 'recordedFetch',
url: 'chrome-extension://fixture/page-recorder-main-world.js',
location: { scriptId: 'hook-script', lineNumber: 10, columnNumber: 1 },
scopeChain: [], this: { type: 'object', description: 'Window' },
}, {
callFrameId: 'page-frame', functionName: '', url: 'https://example.test/login',
location: { scriptId: 'page-script', lineNumber: 162, columnNumber: 38 },
scopeChain: [{ type: 'local', object: { type: 'object', objectId: 'local-scope' } }],
this: { type: 'object', description: 'HTMLFormElement' },
}],
});
await vi.waitFor(async () => expect((await deepCaptureStatus(target)).pause?.collecting).toBe(false));
const result = await deepCaptureStatus(target);
expect(result.pause?.frames[1]?.functionInspection).toMatchObject({
resolved: true, resolution: 'current-function', referenceExpression: 'arguments.callee',
});
expect(result.pause?.automaticCapture).toMatchObject({
state: 'ready', strategy: 'request-transaction', frameId: 'page-frame',
});
expect(debuggerApi.sendCommand).not.toHaveBeenCalledWith(
expect.anything(), 'Debugger.getFunctionLocation', expect.anything(),
);
await resumeDeepCapture(target);
});
it('observes same-origin service-worker targets without routing their pauses into the page capture', async () => {
const target = { tabId: 29, frameId: 0 };
await startDeepCapture(target, { kind: 'request', urlPattern: '/login' });
+106 -39
View File
@@ -20,6 +20,7 @@ import { normalizeCallable } from '@/features/page-callable/service';
import { PAGE_CALLABLE_REGISTRY_KEY } from '@/features/page-callable/constants';
import { rankBusinessFrames } from './business-frame-ranker';
import { getTab } from '@/platform/browser/targets';
import { serializeTabExecution, withPageNetworkGuard } from '@/features/page-callable/network-guard';
interface Debuggee {
tabId?: number;
@@ -58,6 +59,7 @@ interface InspectedFunctionCandidate {
riskFlags: NonNullable<BrowserDeepCaptureFrame['functionInspection']>['riskFlags'];
scriptId?: string;
lineNumber?: number;
columnNumber?: number;
score: number;
}
@@ -71,6 +73,7 @@ interface CDPCallFrame {
callFrameId?: string;
functionName?: string;
location?: { scriptId?: string; lineNumber?: number; columnNumber?: number };
functionLocation?: { scriptId: string; lineNumber: number; columnNumber: number };
url?: string;
scopeChain?: CDPScope[];
this?: CDPRemoteObject;
@@ -114,6 +117,7 @@ const MAX_VALUE_PREVIEW = 512;
const MAX_VARIABLE_DETAIL = 4_096;
const MAX_SCOPE_DETAIL_BUDGET = 16_384;
const MAX_FUNCTION_CANDIDATES = 24;
const RETAINED_FUNCTIONS_KEY = '__YAKIT_DEEP_FUNCTIONS__';
const MAX_WORKER_TARGETS = 16;
const MAX_WORKER_SCRIPT_COUNT = 256;
const acceptedScopeTypes = new Set<BrowserDeepCaptureScope['type']>([
@@ -288,6 +292,8 @@ function debuggerTarget(tabId: number): Debuggee {
function assertSessionOwner(status: StoredDeepCaptureStatus, owner?: DeepCaptureOwner): void {
if (!owner || owner.kind === 'local') return;
// Released local/Agent history is not a live debugger lock.
if (!status.pause && status.recovery?.page === 'running' && status.recovery.debugger === 'detached') return;
if (status.owner.kind !== 'grant' || status.owner.grantId !== owner.grantId) {
throw new ExtensionError('permission_denied', '该页面的深度捕获由另一个会话控制');
}
@@ -616,7 +622,7 @@ async function inspectFunctionExpression(
if (evaluated?.exceptionDetails || evaluated?.result?.type !== 'function' || !evaluated.result.objectId) return undefined;
const objectId = evaluated.result.objectId;
try {
const [metadata, location] = await Promise.all([
const [metadata, properties] = await Promise.all([
sendCommand<{ result?: CDPRemoteObject }>(target, 'Runtime.callFunctionOn', {
objectId,
functionDeclaration: `function () {
@@ -640,8 +646,11 @@ async function inspectFunctionExpression(
returnByValue: true,
silent: true,
}).catch(() => undefined),
sendCommand<{ location?: { scriptId?: string; lineNumber?: number } }>(target, 'Debugger.getFunctionLocation', {
functionId: objectId,
sendCommand<{ internalProperties?: Array<{ name?: string; value?: CDPRemoteObject }> }>(target, 'Runtime.getProperties', {
objectId,
ownProperties: false,
accessorPropertiesOnly: false,
generatePreview: false,
}).catch(() => undefined),
]);
const value = metadata?.result?.value;
@@ -652,14 +661,19 @@ async function inspectFunctionExpression(
if (Array.isArray(input.riskFlags)) {
riskFlags.push(...input.riskFlags.filter((item): item is typeof riskFlags[number] => typeof item === 'string' && allowed.has(item)));
}
const rawLocation = properties?.internalProperties
?.find((property) => property.name === '[[FunctionLocation]]')?.value?.value;
const location = rawLocation && typeof rawLocation === 'object'
? rawLocation as { scriptId?: string; lineNumber?: number; columnNumber?: number }
: undefined;
const functionName = typeof input.functionName === 'string' ? input.functionName.slice(0, 240) : '';
const sameScript = Boolean(location?.location?.scriptId && location.location.scriptId === frame.scriptId);
const sameScript = Boolean(location?.scriptId && location.scriptId === frame.scriptId);
const nameMatch = functionName === frame.functionName || expression === frame.functionName;
let score = resolution === 'frame-name' ? 36 : resolution === 'receiver-method' ? 30 : 12;
let score = resolution === 'current-function' ? 48 : resolution === 'frame-name' ? 36 : resolution === 'receiver-method' ? 30 : 12;
if (sameScript) score += 42;
if (nameMatch) score += 28;
if (sameScript && Number.isFinite(location?.location?.lineNumber)) {
const distance = Math.max(0, frame.lineNumber - (Number(location?.location?.lineNumber) + 1));
if (sameScript && Number.isFinite(location?.lineNumber)) {
const distance = Math.max(0, frame.lineNumber - (Number(location?.lineNumber) + 1));
score += Math.max(0, 12 - Math.min(12, Math.floor(distance / 20)));
}
return {
@@ -671,8 +685,9 @@ async function inspectFunctionExpression(
? input.parameterNames.filter((item): item is string => typeof item === 'string' && validIdentifier(item)).slice(0, 16)
: [],
riskFlags,
scriptId: location?.location?.scriptId,
lineNumber: Number.isFinite(location?.location?.lineNumber) ? Number(location?.location?.lineNumber) + 1 : undefined,
scriptId: location?.scriptId,
lineNumber: Number.isFinite(location?.lineNumber) ? Number(location?.lineNumber) + 1 : undefined,
columnNumber: Number.isFinite(location?.columnNumber) ? Number(location?.columnNumber) + 1 : undefined,
score,
};
} finally {
@@ -689,8 +704,10 @@ async function inspectFrameFunction(
expressions.push({ expression: frame.functionName, resolution: 'frame-name' });
expressions.push({ expression: receiverFunctionExpression(frame.functionName), resolution: 'receiver-method' });
}
if (!validIdentifier(frame.functionName) || frame.functionName === '(anonymous)') {
{
expressions.push({ expression: 'arguments.callee', resolution: 'current-function' });
for (const variable of frame.scopes.flatMap((scope) => scope.variables)) {
if (expressions.length >= MAX_FUNCTION_CANDIDATES) break;
if (variable.type !== 'function' || !validIdentifier(variable.name)) continue;
expressions.push({ expression: variable.name, resolution: 'scope-binding' });
if (expressions.length >= MAX_FUNCTION_CANDIDATES) break;
@@ -700,10 +717,19 @@ async function inspectFrameFunction(
const inspected = (await Promise.all(unique.map((item) => inspectFunctionExpression(
target, frame, item.expression, item.resolution,
)))).filter((item): item is InspectedFunctionCandidate => Boolean(item))
.filter((item) => item.resolution !== 'scope-binding' || item.scriptId === frame.scriptId);
const candidates = [...new Map(inspected
.sort((left, right) => right.score - left.score || left.expression.localeCompare(right.expression))
.map((item) => [`${item.functionName}\n${item.scriptId || ''}\n${item.lineNumber || ''}\n${item.parameterCount}`, item])).values()]
.filter((item) => item.scriptId === frame.scriptId
&& (!frame.functionLocation || (item.lineNumber === frame.functionLocation.lineNumber + 1
&& item.columnNumber === frame.functionLocation.columnNumber + 1)));
if (!inspected.length && frame.functionLocation) {
const listener = await inspectPausedEventListener(target, frame);
if (listener) inspected.push(listener);
}
const distinct = new Map<string, InspectedFunctionCandidate>();
for (const item of inspected.sort((left, right) => right.score - left.score)) {
const key = `${item.scriptId}:${item.lineNumber}:${item.columnNumber}`;
if (!distinct.has(key)) distinct.set(key, item);
}
const candidates = [...distinct.values()]
.sort((left, right) => right.score - left.score || left.expression.localeCompare(right.expression));
const selected = candidates[0];
const ambiguous = Boolean(selected && candidates[1] && selected.score - candidates[1].score < 8);
@@ -719,6 +745,54 @@ async function inspectFrameFunction(
};
}
async function inspectPausedEventListener(target: Debuggee, frame: BrowserDeepCaptureFrame): Promise<InspectedFunctionCandidate | undefined> {
const matches: CDPRemoteObject[] = [];
// Query the browser's listener registry, including delegated and shadow-tree listeners.
// Unlike arguments.callee this also works for strict functions and arrow functions.
for (const expression of ['document', 'window']) {
const evaluated = await sendCommand<{ result?: CDPRemoteObject }>(target, 'Debugger.evaluateOnCallFrame', {
callFrameId: frame.id, expression, objectGroup: 'yakit-deep-capture', silent: true,
}).catch(() => undefined);
if (!evaluated?.result?.objectId) continue;
try {
const result = await sendCommand<{ listeners?: Array<{
scriptId: string; lineNumber: number; columnNumber: number;
handler?: CDPRemoteObject; originalHandler?: CDPRemoteObject;
}> }>(target, 'DOMDebugger.getEventListeners', {
objectId: evaluated.result.objectId, depth: -1, pierce: true,
});
for (const listener of result.listeners || []) {
if (listener.scriptId !== frame.scriptId
|| listener.lineNumber !== frame.functionLocation!.lineNumber
|| listener.columnNumber !== frame.functionLocation!.columnNumber) continue;
const handler = listener.originalHandler || listener.handler;
if (handler?.objectId && handler.type === 'function') matches.push(handler);
}
} finally { await sendCommand(target, 'Runtime.releaseObject', { objectId: evaluated.result.objectId }).catch(() => undefined); }
}
if (!matches.length) return undefined;
const first = matches[0];
try {
for (const other of matches.slice(1)) {
const equal = await sendCommand<{ result?: CDPRemoteObject }>(target, 'Runtime.callFunctionOn', {
objectId: first.objectId, functionDeclaration: 'function(other) { return this === other; }',
arguments: [{ objectId: other.objectId }], returnByValue: true,
});
if (equal.result?.value !== true) return undefined;
}
const id = crypto.randomUUID();
await sendCommand(target, 'Runtime.callFunctionOn', {
objectId: first.objectId,
functionDeclaration: `function(id) { (globalThis[${JSON.stringify(RETAINED_FUNCTIONS_KEY)}] ||= Object.create(null))[id] = this; }`,
arguments: [{ value: id }],
});
return await inspectFunctionExpression(target, frame,
`globalThis[${JSON.stringify(RETAINED_FUNCTIONS_KEY)}][${JSON.stringify(id)}]`, 'event-listener');
} finally {
await Promise.all(matches.map((handler) => sendCommand(target, 'Runtime.releaseObject', { objectId: handler.objectId }).catch(() => undefined)));
}
}
function pauseSkeleton(
params: Record<string, unknown>,
matcher?: BrowserDeepCaptureMatcher,
@@ -741,6 +815,9 @@ function pauseSkeleton(
sourceMapUrl: script?.sourceMapUrl,
lineNumber: Math.max(1, Number(frame.location?.lineNumber || 0) + 1),
columnNumber: Math.max(1, Number(frame.location?.columnNumber || 0) + 1),
functionLocation: frame.functionLocation
? { lineNumber: frame.functionLocation.lineNumber, columnNumber: frame.functionLocation.columnNumber }
: undefined,
scopes: [],
thisPreview: remotePreview(frame.this),
sourceKind,
@@ -1352,6 +1429,7 @@ async function capturePageCallableWhilePaused(
delete globalThis[${JSON.stringify(retainedCallKey)}];
if (!retainedCall || !Array.isArray(retainedCall.args)) throw new Error("业务函数的暂停现场已经失效");
const candidate = (${functionExpression});
delete globalThis[${JSON.stringify(RETAINED_FUNCTIONS_KEY)}];
if (typeof candidate !== "function") throw new Error("选中的表达式不是函数");
const source = Function.prototype.toString.call(candidate).slice(0, 65536);
const transaction = ${JSON.stringify(requestTransaction || null)};
@@ -1368,31 +1446,6 @@ async function capturePageCallableWhilePaused(
registry = new Map();
Object.defineProperty(globalThis, key, { value: registry, configurable: true, enumerable: false });
}
if (transaction && typeof globalThis.fetch === "function") {
const previousFetch = globalThis.fetch;
let restoreTimer;
const restoreFetch = () => {
if (globalThis.fetch === transactionCaptureFetch) globalThis.fetch = previousFetch;
if (restoreTimer) clearTimeout(restoreTimer);
};
const transactionCaptureFetch = async function(input, init) {
let request;
try { request = new Request(input, init); } catch { return Reflect.apply(previousFetch, this, [input, init]); }
const expectedURL = new URL(transaction.request.url, location.href).toString();
if (transaction.request.boundary !== "fetch"
|| request.method.toUpperCase() !== transaction.request.method.toUpperCase()
|| request.url !== expectedURL) {
return Reflect.apply(previousFetch, this, [input, init]);
}
restoreFetch();
return new Response(JSON.stringify({ success: false, error: "request captured before transaction replay" }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
};
globalThis.fetch = transactionCaptureFetch;
restoreTimer = setTimeout(restoreFetch, 10000);
}
const metadata = {
id: ${JSON.stringify(callableId)}, name: ${JSON.stringify(name)}, kind: ${JSON.stringify(callableKind)},
operation: candidate.name || ${JSON.stringify(functionExpression)}, origin: location.origin,
@@ -1476,6 +1529,20 @@ export async function createCapturedPageCallable(
callFrameId: string,
input: CapturedPageCallableInput,
owner?: DeepCaptureOwner,
): Promise<BrowserPageCallable> {
if (input.strategy === 'request-transaction') {
return serializeTabExecution(target.tabId, () => withPageNetworkGuard(
target, [], () => captureAndResume(target, callFrameId, input, owner), input.transaction.request.url,
));
}
return captureAndResume(target, callFrameId, input, owner);
}
async function captureAndResume(
target: BrowserTarget,
callFrameId: string,
input: CapturedPageCallableInput,
owner?: DeepCaptureOwner,
): Promise<BrowserPageCallable> {
let callable: BrowserPageCallable | undefined;
let captureError: unknown;
@@ -145,6 +145,7 @@ vi.stubGlobal('WebSocket', FakeWebSocket);
import {
BRIDGE_HEARTBEAT_TIMEOUT_MS,
EngineBridge,
browserClientIdentity,
} from './service';
import { beginAgentAction } from '@/features/agent-runtime/service';
import {
@@ -162,6 +163,8 @@ function bridgeConfig(paired = true) {
endpoint: 'ws://127.0.0.1:64333/extension',
autoConnect: false,
installationId: 'installation-1',
browserName: 'Chrome for Testing',
browserVersion: '152.0.7977.82',
pairedEngine: paired ? {
engineIdentityId: 'engine-identity-1',
deviceId: 'device-1',
@@ -346,6 +349,8 @@ describe('Engine Bridge transport lifecycle', () => {
expect(auth).toMatchObject({
type: 'auth',
client: 'Chrome for Testing',
version: '152.0.7977.82',
challenge: 'engine-challenge-0123456789',
resumeSessionId: 'previous-session',
});
@@ -494,3 +499,21 @@ describe('Engine Bridge transport lifecycle', () => {
});
});
});
describe('browser client identity', () => {
it('distinguishes Edge and lets managed Chrome for Testing metadata win', () => {
const config = { ...bridgeConfig(false), browserName: undefined, browserVersion: undefined };
expect(browserClientIdentity(
config,
'Mozilla/5.0 AppleWebKit/537.36 Chrome/152.0.0.0 Safari/537.36 Edg/152.0.1234.5',
)).toEqual({ client: 'Microsoft Edge', version: '152.0.1234.5' });
expect(browserClientIdentity({
...config,
browserName: 'Chrome for Testing',
browserVersion: '152.0.7977.82',
}, 'Mozilla/5.0 Chrome/152.0.0.0')).toEqual({
client: 'Chrome for Testing',
version: '152.0.7977.82',
});
});
});
+22 -3
View File
@@ -33,6 +33,23 @@ const MAX_CONCURRENT_REQUESTS = 8;
const ENGINE_REQUEST_TIMEOUT = 10_000;
const MAX_OUTGOING_REQUESTS = 4;
export function browserClientIdentity(
config: BridgeConfig,
userAgent = globalThis.navigator?.userAgent || '',
): { client: string; version: string } {
const detected = [
[/\bEdg(?:A|iOS)?\/([\d.]+)/, 'Microsoft Edge'],
[/\b(?:Chrome|CriOS)\/([\d.]+)/, 'Google Chrome'],
[/\bChromium\/([\d.]+)/, 'Chromium'],
[/\bFirefox\/([\d.]+)/, 'Firefox'],
].map(([pattern, name]) => ({ match: userAgent.match(pattern as RegExp), name: name as string }))
.find(({ match }) => match);
return {
client: config.browserName?.trim() || detected?.name || 'Browser',
version: config.browserVersion?.trim() || detected?.match?.[1] || '',
};
}
interface OutgoingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
@@ -297,10 +314,11 @@ export class EngineBridge {
const [state, previousSession] = await Promise.all([getState(), getBridgeRuntimeSession()]);
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
const capabilityCatalog = await getBridgeCapabilityCatalog();
const browserIdentity = browserClientIdentity(config);
const auth: BridgeEnvelope = {
type: 'auth',
client: 'yakit-browser-extension',
version: browser.runtime.getManifest().version,
client: browserIdentity.client,
version: browserIdentity.version,
protocolVersion: BRIDGE_PROTOCOL_VERSION,
capabilities: [...BRIDGE_CAPABILITIES],
capabilityCatalog,
@@ -781,6 +799,7 @@ export class EngineBridge {
if (this.pairingSocket && ['requesting', 'pending'].includes(currentPairing.state)) return currentPairing;
this.cancelPairing(false);
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
const browserIdentity = browserClientIdentity(config);
const clientNonce = randomBridgeNonce();
const pairingURL = new URL(config.endpoint);
pairingURL.pathname = '/pairing';
@@ -805,7 +824,7 @@ export class EngineBridge {
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
installationId: config.installationId,
managedInstance: config.managedInstance,
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
client: browserIdentity.client, version: browserIdentity.version,
nonce: clientNonce, publicKey: identity.publicKey,
} satisfies BridgePairingEnvelope));
} catch (error) {
@@ -11,6 +11,7 @@ import { AGENT_RUNTIME_STORAGE_KEY, isStateStorageChange } from '@/protocol/stor
import type { ActiveTabInfo, AgentRuntime, BridgeStatus, ExtensionState, PageContext } from '@/types/models';
import { errorMessage, request } from '@/platform/messaging/runtime';
import { isFloatingPanelShortcut, mergeFloatingTabUpdate } from './host-controller';
import { ProxyStatusBar, StartupProxyOption, useProxyStatus } from '@/features/proxy/ui/ProxyStatusBar';
interface FloatingPanelProps {
initialState: ExtensionState;
@@ -21,6 +22,7 @@ interface FloatingPanelProps {
export function FloatingPanel({ initialState, initialTab, initialBridge, hostChannel }: FloatingPanelProps) {
const [state, setState] = useState(initialState);
const proxyStatus = useProxyStatus(state);
const [bridge, setBridge] = useState(initialBridge);
const [tab, setTab] = useState(initialTab);
const [busy, setBusy] = useState(false);
@@ -142,15 +144,17 @@ export function FloatingPanel({ initialState, initialTab, initialBridge, hostCha
</TabsList>
<TabsContent value="proxy" className="floating-tab-content">
<ProxyStatusBar status={proxyStatus} />
<div className="floating-section-heading"><span></span><Button size="icon" variant="ghost" title="代理设置" onClick={() => openWorkspace('proxies')}><Settings size={15} /></Button></div>
<div className="floating-option-list">
<StartupProxyOption state={state} status={proxyStatus} setState={setState} run={run} busy={busy} />
{state.proxyProfiles.map((profile) => (
<button key={profile.id} className={state.activeProxyId === profile.id ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })))}>
<button key={profile.id} className={proxyStatus.activeProfileId === profile.id ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })))}>
<i className="floating-radio" />
<span><strong>{profile.name}</strong><small>{profile.kind === 'fixed_servers' ? `${profile.host}:${profile.port}` : profile.kind}</small></span>
</button>
))}
<button className={state.activeProxyId === 'auto' ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.auto.apply')))}><i className="floating-radio" /><span><strong></strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} · {state.proxyRuleSources.filter((source) => source.enabled).length} </small></span></button>
<button className={proxyStatus.activeProfileId === 'auto' ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.auto.apply')))}><i className="floating-radio" /><span><strong></strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} · {state.proxyRuleSources.filter((source) => source.enabled).length} </small></span></button>
</div>
</TabsContent>
+2 -1
View File
@@ -43,7 +43,8 @@ export const NETWORK_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
export const RECORDING_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
id: 'recording-callable-debugger',
owns: (method) => method.startsWith('browser.recording.')
owns: (method) => method === 'browser.crypto.inspect'
|| method.startsWith('browser.recording.')
|| method.startsWith('browser.callable.')
|| method.startsWith('browser.deep_capture.'),
};
@@ -33,10 +33,28 @@ import {
stageBrowserProfileEvidence,
} from '@/features/browser-analysis/service';
import { RECORDING_CAPABILITY_DOMAIN } from '../capability-domains';
import { inspectPageCryptoOperation } from '@/features/browser-crypto/inspect';
export const recordingCapabilityHandler: CapabilityDomainHandler = {
...RECORDING_CAPABILITY_DOMAIN,
async handle({ method, input, grant }) {
if (method === 'browser.crypto.inspect') {
for (const scope of [
'browser.recording.control',
'browser.recording.sensitive.read',
'browser.network.capture',
'browser.network.sensitive.read',
] as const) requireScope(grant, scope);
return inspectPageCryptoOperation(
await allowedTarget(grant, input),
{
captureId: String(input.captureId || ''),
nodeId: String(input.nodeId || ''),
settleMs: typeof input.settleMs === 'number' ? input.settleMs : undefined,
},
{ grantId: grant.id, expiresAt: grant.expiresAt },
);
}
if (method.startsWith('browser.recording.')) {
const target = await allowedTarget(grant, input);
if (method === 'browser.recording.trace.list') {
@@ -140,6 +158,9 @@ export const recordingCapabilityHandler: CapabilityDomainHandler = {
return createRecordedPageCallable(target, {
callHandleId: String(input.callHandleId || ''),
name: String(input.name || ''),
dynamicInputPaths: Array.isArray(input.dynamicInputPaths)
? input.dynamicInputPaths.map(String)
: undefined,
});
}
if (method === 'browser.callable.execute') {
@@ -1,6 +1,8 @@
import type {
BrowserTransformExecuteInput,
BrowserTransformPacket,
BrowserTransformValidationExecuteInput,
BrowserTransformProfileInput,
} from '@/types/models';
import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget, requireScope } from '../capability-context';
@@ -12,13 +14,17 @@ import {
getBrowserTransformProfile,
getBrowserTransformRecovery,
listBrowserTransformProfiles,
saveBrowserTransformProfile,
resetBrowserTransformRecovery,
startBrowserTransformRecovery,
validateBrowserTransformRecovery,
} from '@/features/browser-transform/service';
import {
compareBrowserPackets,
browserTransformValidationById,
executeBrowserTransformValidation,
latestBrowserTransformValidation,
prepareCapturedBrowserTransformProfile,
proposeBrowserTransformProfile,
validateInferredBrowserTransformProfile,
} from '@/features/browser-analysis/service';
@@ -27,6 +33,39 @@ import { TRANSFORM_CAPABILITY_DOMAIN } from '../capability-domains';
export const transformCapabilityHandler: CapabilityDomainHandler = {
...TRANSFORM_CAPABILITY_DOMAIN,
async handle({ method, input, grant }) {
if (method === 'browser.transform.profile.save') {
const profile = input as unknown as BrowserTransformProfileInput;
await allowedTarget(grant, profile.target);
return saveBrowserTransformProfile(profile);
}
if (method === 'browser.transform.validation.get') {
const draft = await browserTransformValidationById(String(input.validationId || ''));
await allowedTarget(grant, draft.profile.target);
return {
id: draft.id, expiresAt: draft.expiresAt,
directions: { request: draft.profile.request.enabled, response: draft.profile.response.enabled },
};
}
if (method === 'browser.transform.prepare') {
requireScope(grant, 'browser.recording.read');
requireScope(grant, 'browser.callable.execute');
return prepareCapturedBrowserTransformProfile(
await allowedTarget(grant, input),
String(input.candidateId || ''),
input.packet as BrowserTransformPacket,
Array.isArray(input.inputPaths) ? input.inputPaths.map(String) : undefined,
typeof input.name === 'string' ? input.name : undefined,
{
owner: { kind: 'grant', grantId: grant.id, expiresAt: grant.expiresAt },
trigger: input.trigger as { captureId: string; nodeId: string } | undefined,
authorize: () => {
requireScope(grant, 'browser.debugger.control');
requireScope(grant, 'browser.dom.read');
requireScope(grant, 'browser.dom.write');
},
},
);
}
if (method === 'browser.packet.compare') {
await allowedTarget(grant, input);
return compareBrowserPackets(
@@ -123,6 +162,17 @@ export const transformCapabilityHandler: CapabilityDomainHandler = {
await allowedTarget(grant, profile.target);
return deleteBrowserTransformProfile(profile.id);
}
if (method === 'browser.transform.validation.execute') {
requireScope(grant, 'browser.transform.execute');
const executeValidationInput = input as unknown as BrowserTransformValidationExecuteInput;
const draft = await browserTransformValidationById(executeValidationInput.validationId);
await allowedTarget(grant, draft.profile.target);
return executeBrowserTransformValidation(
executeValidationInput.validationId,
executeValidationInput.direction,
executeValidationInput.packet,
);
}
const executeInput = input as unknown as BrowserTransformExecuteInput;
const profile = await getBrowserTransformProfile(executeInput.profileId);
await allowedTarget(grant, profile.target);
+103
View File
@@ -0,0 +1,103 @@
import { browser, type Browser } from 'wxt/browser';
import type { BrowserTarget, BrowserPageCallableTransaction } from '@/types/models';
import { ExtensionError } from '@/shared/errors';
import { scriptingTarget } from '@/platform/browser/targets';
const RULE_BASE = 1_000_000;
const RULE_LIMIT = RULE_BASE + 10_000;
const queues = new Map<number, Promise<unknown>>();
let ruleQueue: Promise<unknown> = Promise.resolve();
// DNR is tab-scoped: all callables and profiles in that tab share this gate.
export function serializeTabExecution<T>(tabId: number, run: () => Promise<T>): Promise<T> {
const previous = queues.get(tabId) || Promise.resolve();
const result = previous.catch(() => undefined).then(run);
queues.set(tabId, result);
void result.finally(() => { if (queues.get(tabId) === result) queues.delete(tabId); }).catch(() => undefined);
return result;
}
async function installRules(tabId: number, prerequisites: BrowserPageCallableTransaction['prerequisites']): Promise<number[]> {
const run = async () => {
const occupied = new Set((await browser.declarativeNetRequest.getSessionRules()).map((rule) => rule.id));
let nextId = RULE_BASE;
const allocate = () => {
while (occupied.has(nextId)) nextId++;
if (nextId >= RULE_LIMIT) throw new Error('页面网络隔离规则已满');
return nextId++;
};
const rules: Browser.declarativeNetRequest.Rule[] = [{
id: allocate(), priority: 100_000, action: { type: 'block' },
condition: {
tabIds: [tabId], urlFilter: '*',
// Omitting resourceTypes excludes main_frame and lets native form navigation escape.
resourceTypes: ['main_frame', 'sub_frame', 'stylesheet', 'script', 'image', 'font', 'object',
'xmlhttprequest', 'ping', 'csp_report', 'media', 'websocket', 'other'],
},
}];
for (const step of prerequisites) {
const url = new URL(step.url);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('在线前置请求必须使用 HTTP(S)');
rules.push({
id: allocate(), priority: 100_001, action: { type: 'allow' },
condition: {
tabIds: [tabId], regexFilter: `^${url.href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
requestMethods: [step.method.toLowerCase() as Browser.declarativeNetRequest.RequestMethod],
resourceTypes: ['xmlhttprequest'],
},
});
}
await browser.declarativeNetRequest.updateSessionRules({ addRules: rules });
return rules.map((rule) => rule.id);
};
const result = ruleQueue.then(run, run);
ruleQueue = result.catch(() => undefined);
return result;
}
export async function withPageNetworkGuard<T>(
target: BrowserTarget,
prerequisites: BrowserPageCallableTransaction['prerequisites'],
run: () => Promise<T>,
captureURL?: string,
): Promise<T> {
const ids = await installRules(target.tabId, prerequisites);
let blocked: string | undefined;
let captured!: () => void;
const capturedRequest = new Promise<void>((resolve) => { captured = resolve; });
const onError = (details: Browser.webRequest.OnErrorOccurredDetails) => {
if (details.tabId === target.tabId && /BLOCKED_BY_CLIENT|NS_ERROR_ABORT/.test(details.error)) {
blocked = details.url;
if (details.url === captureURL) captured();
}
};
try {
browser.webRequest.onErrorOccurred.addListener(onError, { urls: ['<all_urls>'], tabId: target.tabId });
let value: T;
try {
value = await run();
} finally {
// Native form navigation is queued in the renderer. Drain it before removing
// browser protection, including when the callable failed during rollback.
await browser.scripting.executeScript({
target: scriptingTarget(target),
func: () => new Promise<void>((resolve) => setTimeout(resolve, 0)),
}).catch(() => undefined); // A destroyed document has no queued navigation to drain.
}
if (captureURL) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([capturedRequest, new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('捕获后未观察到目标请求被浏览器取消,不能确认捕获完成')), 10_000);
})]);
} finally { clearTimeout(timer); }
}
// Drain browser request-error delivery before reporting a successful replay.
await new Promise((resolve) => setTimeout(resolve, 0));
if (blocked && !captureURL) throw new ExtensionError('callable_network_blocked', `回放尝试绕过页面拦截,浏览器已阻止请求:${blocked}`);
return value;
} finally {
browser.webRequest.onErrorOccurred.removeListener(onError);
await browser.declarativeNetRequest.updateSessionRules({ removeRuleIds: ids });
}
}
@@ -1,149 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { BrowserPageCallableTransaction } from '@/types/models'
import { executeRequestTransaction } from './request-transaction'
const KEY_URL = 'http://127.0.0.1:82/encrypt/server_generate_key.php'
const FINAL_URL = 'http://127.0.0.1:82/encrypt/aesserver.php'
const transaction: BrowserPageCallableTransaction = {
version: 2,
prerequisites: [{
boundary: 'fetch',
method: 'GET',
url: KEY_URL,
requestBodyFormat: 'none',
maxRequestBodyBytes: 16 * 1_024,
response: {
statusCode: 200,
url: KEY_URL,
bodyFormat: 'json',
maxBodyBytes: 64 * 1_024,
requiredPaths: ['body.aes_key', 'body.aes_iv'],
},
}],
request: {
boundary: 'fetch',
method: 'POST',
url: FINAL_URL,
expectedDestinations: ['body.encryptedData'],
bodyFormat: 'json',
},
inputMode: 'auto',
}
const replacedGlobals = new Map<string, PropertyDescriptor | undefined>()
function replaceGlobal(name: string, value: unknown): void {
if (!replacedGlobals.has(name)) replacedGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name))
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true })
}
function response(url: string, body: unknown): Response {
const result = new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
Object.defineProperty(result, 'url', { value: url, configurable: true })
return result
}
function installPageRuntime(fetch: typeof globalThis.fetch): void {
class FakeXMLHttpRequest {
open(): void {}
setRequestHeader(): void {}
send(): void {}
}
class FakeHTMLFormElement {
submit(): void {}
requestSubmit(): void {}
}
const pageWindow = {
fetch,
setTimeout: globalThis.setTimeout.bind(globalThis),
alert: () => undefined,
confirm: () => false,
prompt: () => null,
open: () => null,
}
replaceGlobal('window', pageWindow)
replaceGlobal('location', { href: 'http://127.0.0.1:82/' })
replaceGlobal('document', {
documentElement: null,
querySelectorAll: () => [],
addEventListener: () => undefined,
removeEventListener: () => undefined,
})
replaceGlobal('navigator', {})
replaceGlobal('XMLHttpRequest', FakeXMLHttpRequest)
replaceGlobal('HTMLFormElement', FakeHTMLFormElement)
replaceGlobal('HTMLButtonElement', class FakeHTMLButtonElement {})
replaceGlobal('HTMLInputElement', class FakeHTMLInputElement {})
}
afterEach(() => {
for (const [name, descriptor] of replacedGlobals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
else delete (globalThis as Record<string, unknown>)[name]
}
replacedGlobals.clear()
})
describe('request transaction runtime', () => {
it('executes a proven prerequisite and captures the terminal request without sending it', async () => {
const network: string[] = []
installPageRuntime(async (request) => {
const url = request instanceof Request ? request.url : String(request)
network.push(url)
return response(url, { aes_key: 'dynamic-key', aes_iv: 'dynamic-iv' })
})
const result = await executeRequestTransaction({
transaction,
logicalInput: { username: 'admin', password: '123456' },
timeoutMs: 1_000,
invoke: async () => {
const keyResponse = await window.fetch(KEY_URL)
const key = await keyResponse.json() as { aes_key: string; aes_iv: string }
await window.fetch(FINAL_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ encryptedData: `${key.aes_key}:${key.aes_iv}:ciphertext` }),
})
},
})
expect(result).toEqual({ encryptedData: 'dynamic-key:dynamic-iv:ciphertext' })
expect(network).toEqual([KEY_URL])
})
it('fails closed before the network when the page requests an unproven prerequisite', async () => {
const network: string[] = []
installPageRuntime(async (request) => {
network.push(request instanceof Request ? request.url : String(request))
return response(KEY_URL, { aes_key: 'dynamic-key', aes_iv: 'dynamic-iv' })
})
await expect(executeRequestTransaction({
transaction,
logicalInput: {},
timeoutMs: 1_000,
invoke: async () => {
await window.fetch('http://127.0.0.1:82/unrelated')
},
})).rejects.toThrow('页面尝试访问未授权请求 GET http://127.0.0.1:82/unrelated')
expect(network).toEqual([])
})
it('rejects a prerequisite response that does not contain the proven dynamic inputs', async () => {
installPageRuntime(async () => response(KEY_URL, { value: 'not-a-key-envelope' }))
await expect(executeRequestTransaction({
transaction,
logicalInput: {},
timeoutMs: 1_000,
invoke: async () => {
await window.fetch(KEY_URL)
},
})).rejects.toThrow('缺少目标字段:body.aes_key、body.aes_iv')
})
})
+270 -159
View File
@@ -1,11 +1,23 @@
import type { BrowserPageCallableExecutionPolicy, BrowserPageCallableTransaction } from '@/types/models'
import { callableExecutionPolicy, settleCallableResult } from './execution'
import { readRequestBody } from '@/shared/request-body'
const MAX_BODY_BYTES = 8 * 1024 * 1024
const MAX_CONTROLS = 2_000
const MAX_FIELDS = 64
const MAX_MUTATIONS = 2_000
const DEFAULT_TIMEOUT_MS = 4_000
const nativeSetTimeout = globalThis.setTimeout.bind(globalThis)
let executionQueue: Promise<unknown> = Promise.resolve()
let observeInput: ((value: unknown) => void) | undefined
export function observeCallableInput(value: unknown): void { observeInput?.(value) }
export function serializePageExecution<T>(run: () => Promise<T>): Promise<T> {
const result = executionQueue.then(run, run)
executionQueue = result.catch(() => undefined)
return result
}
interface CapturedRequest {
boundary: 'fetch' | 'xhr' | 'beacon' | 'form'
@@ -24,6 +36,7 @@ export interface RequestTransactionInvocation {
logicalInput: unknown
invoke(context: TransactionContext): unknown
timeoutMs?: number
observeInputs?(): () => void
}
interface RollbackController {
@@ -44,7 +57,7 @@ function error(message: string): Error {
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
return new Promise((resolve) => nativeSetTimeout(resolve, milliseconds))
}
function absoluteUrl(value: string): string {
@@ -120,6 +133,14 @@ function headerRecord(headers: Headers): Record<string, string> {
return output
}
function normalizedBodyHeaders(body: unknown, headers: Record<string, string>): Record<string, string> {
if (body instanceof FormData) return { ...headers, 'content-type': 'application/x-www-form-urlencoded' }
if (headers['content-type']) return headers
if (body instanceof URLSearchParams) return { ...headers, 'content-type': 'application/x-www-form-urlencoded' }
if (body instanceof Blob && body.type) return { ...headers, 'content-type': body.type }
return headers
}
function parseForm(value: string): Record<string, string | string[]> {
const output: Record<string, string | string[]> = Object.create(null) as Record<string, string | string[]>
for (const [key, item] of new URLSearchParams(value)) {
@@ -285,11 +306,21 @@ function setControlValue(control: MutableControl, value: unknown): void {
return
}
if ('value' in control) {
control.value = value === undefined || value === null ? ''
const text = value === undefined || value === null ? ''
: typeof value === 'object' ? JSON.stringify(value) : String(value)
let prototype = Object.getPrototypeOf(control)
while (prototype && !Object.getOwnPropertyDescriptor(prototype, 'value')?.set) prototype = Object.getPrototypeOf(prototype)
const setter = prototype && Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (setter) setter.call(control, text)
else control.value = text
}
}
function notifyControl(control: MutableControl): void {
control.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
control.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
}
function bindLogicalInput(value: unknown): number {
const fields = logicalFields(value)
if (!fields.length) return 0
@@ -304,7 +335,9 @@ function bindLogicalInput(value: unknown): number {
missing.push(field.path)
continue
}
candidates.forEach((control) => setControlValue(control, field.value))
const owners = new Set(candidates.map((control) => control.closest('form') || document))
if (owners.size > 1) throw error(`明文字段 ${field.path} 对应多个表单,不能猜测输入目标`)
candidates.forEach((control) => { setControlValue(control, field.value); notifyControl(control) })
matched += 1
}
if (matched && missing.length) throw error(`无法把明文字段映射到页面输入:${missing.join('、')}`)
@@ -342,9 +375,11 @@ function beginDomRollback(): RollbackController {
if (observer) mutations.push(...observer.takeRecords().slice(0, Math.max(0, MAX_MUTATIONS - mutations.length)))
observer?.disconnect()
for (const snapshot of controlSnapshots) {
if (snapshot.value !== undefined) snapshot.control.value = snapshot.value
const changed = snapshot.control.value !== snapshot.value || snapshot.control.checked !== snapshot.checked
if (snapshot.value !== undefined) setControlValue(snapshot.control, snapshot.value)
if (snapshot.checked !== undefined) snapshot.control.checked = snapshot.checked
if (snapshot.selectedIndex !== undefined) snapshot.control.selectedIndex = snapshot.selectedIndex
if (changed) notifyControl(snapshot.control)
}
for (const mutation of [...mutations].reverse()) {
try {
@@ -372,9 +407,10 @@ function setMethod<T extends object, K extends keyof T>(target: T, key: K, value
const previous = target[key]
try {
target[key] = value
if (target[key] !== value) throw new Error('属性不可写')
restorers.push(() => { target[key] = previous })
} catch {
// A non-writable optional boundary remains protected by the other installed boundaries.
} catch (reason) {
throw error(`不能隔离页面边界 ${String(key)}${String(reason)}`)
}
}
@@ -394,151 +430,185 @@ function formRequest(form: HTMLFormElement, submitter?: HTMLElement | null): Cap
}
}
export async function executeRequestTransaction(input: RequestTransactionInvocation): Promise<unknown> {
export function executeRequestTransaction(input: RequestTransactionInvocation): Promise<unknown> {
return serializePageExecution(() => runRequestTransaction(input))
}
async function runRequestTransaction(input: RequestTransactionInvocation): Promise<unknown> {
const timeoutMs = callableExecutionPolicy('auto', input.timeoutMs ?? DEFAULT_TIMEOUT_MS).timeoutMs
const rollback = beginDomRollback()
const restorers: Array<() => void> = []
let captured: CapturedRequest | undefined
let captureFailure: Error | undefined
let prerequisiteIndex = 0
let prerequisiteInFlight = false
let resolveCapture!: () => void
const captureSignal = new Promise<void>((resolve) => { resolveCapture = resolve })
const transactionAbort = new AbortController()
const fail = (reason: unknown): Error => {
const message = reason instanceof Error ? reason.message : String(reason)
const failure = reason instanceof Error && message.startsWith('请求事务失败:') ? reason : error(message)
captureFailure = failure
if (!transactionAbort.signal.aborted) transactionAbort.abort(failure)
resolveCapture()
return failure
}
const capture = async (request: CapturedRequest): Promise<void> => {
if (captured || captureFailure) {
throw fail('页面流程产生了目标请求之外的额外网络请求')
let cancelTasks: (() => void) | undefined
let restoreObservation: (() => void) | undefined
try {
cancelTasks = trackInvocationTasks()
restoreObservation = input.observeInputs?.()
const fields = logicalFields(input.logicalInput)
if (!fields.length) {
let value = input.logicalInput
if (typeof value === 'string') { try { value = JSON.parse(value) } catch { /* Raw plaintext. */ } }
fields.push({ path: '', key: '', value })
}
if (prerequisiteInFlight || prerequisiteIndex !== input.transaction.prerequisites.length) {
throw fail('页面在在线前置请求完成前尝试生成最终业务请求')
}
if (!requestMatchesStep(input.transaction.request, request)) {
throw fail(`页面尝试访问未授权请求 ${request.method} ${request.url}`)
}
if (byteLength(request.bodyText) > MAX_BODY_BYTES) {
throw fail('页面生成的请求 Body 超过 8 MiB')
}
captured = request
resolveCapture()
}
const previousFetch = window.fetch
setMethod(window, 'fetch', (async function transactionFetch(this: Window, requestInput: RequestInfo | URL, init?: RequestInit) {
const request = new Request(resolveRequestTransactionFetchInput(requestInput), init)
const observed: CapturedRequest = {
boundary: 'fetch',
method: request.method.toUpperCase(),
url: request.url,
headers: headerRecord(request.headers),
bodyText: await request.clone().text(),
}
const prerequisite = input.transaction.prerequisites[prerequisiteIndex]
if (prerequisite) {
if (prerequisiteInFlight) throw fail('页面并发发起了多个在线前置请求,无法证明执行顺序')
if (!requestMatchesStep(prerequisite, observed)) {
throw fail(`页面尝试访问未授权请求 ${observed.method} ${observed.url}`)
const consumed = new Set<string>()
observeInput = (value) => {
if (value instanceof ArrayBuffer) value = new TextDecoder().decode(value)
else if (ArrayBuffer.isView(value)) value = new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength))
if (typeof value === 'string') {
try { value = JSON.parse(value) } catch { /* Individual plaintext arguments are also supported. */ }
}
let forwardAbort: (() => void) | undefined
try {
validatePrerequisiteRequest(prerequisite, observed)
prerequisiteInFlight = true
if (request.signal.aborted) transactionAbort.abort(request.signal.reason)
else {
forwardAbort = () => transactionAbort.abort(request.signal.reason)
request.signal.addEventListener('abort', forwardAbort, { once: true })
}
const expectedRedirect = comparableUrl(prerequisite.url, runtimeBaseUrl())
=== comparableUrl(prerequisite.response.url, runtimeBaseUrl()) ? 'error' : 'follow'
const guardedRequest = new Request(request, {
redirect: expectedRedirect,
signal: transactionAbort.signal,
})
const response = await Reflect.apply(previousFetch, this, [guardedRequest])
await validatePrerequisiteResponse(prerequisite, response)
prerequisiteIndex += 1
return response
} catch (reason) {
throw fail(reason)
} finally {
if (forwardAbort) request.signal.removeEventListener('abort', forwardAbort)
prerequisiteInFlight = false
for (const field of fields) {
const actual = value && typeof value === 'object' ? readOwnPath(value, field.path) : value
try {
if (JSON.stringify(actual) === JSON.stringify(field.value)) consumed.add(field.path)
} catch { /* Opaque crypto values do not prove an input binding. */ }
}
}
await capture(observed)
return await new Promise<Response>(() => undefined)
}) as typeof previousFetch, restorers)
let captured: CapturedRequest | undefined
let captureFailure: Error | undefined
let prerequisiteIndex = 0
let prerequisiteInFlight = false
let resolveCapture!: () => void
const captureSignal = new Promise<void>((resolve) => { resolveCapture = resolve })
const xhrMetadata = new WeakMap<XMLHttpRequest, { method: string; url: string; headers: Record<string, string> }>()
const xhrPrototype = XMLHttpRequest.prototype
const previousOpen = xhrPrototype.open
const previousSetHeader = xhrPrototype.setRequestHeader
const previousSend = xhrPrototype.send
setMethod(xhrPrototype, 'open', (function transactionOpen(this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
xhrMetadata.set(this, { method: method.toUpperCase(), url: absoluteUrl(String(url)), headers: Object.create(null) as Record<string, string> })
return Reflect.apply(previousOpen, this, [method, url, ...rest] as never)
}) as typeof previousOpen, restorers)
setMethod(xhrPrototype, 'setRequestHeader', (function transactionSetHeader(this: XMLHttpRequest, name: string, value: string) {
const metadata = xhrMetadata.get(this)
if (metadata) metadata.headers[name.toLowerCase()] = value
return Reflect.apply(previousSetHeader, this, [name, value])
}) as typeof previousSetHeader, restorers)
setMethod(xhrPrototype, 'send', (function transactionSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
const metadata = xhrMetadata.get(this)
if (!metadata) throw error('XHR 没有可验证的 open 边界')
void bodyText(body).then((text) => capture({ boundary: 'xhr', ...metadata, bodyText: text })).catch((reason) => {
captureFailure = reason instanceof Error ? reason : error(String(reason))
const fail = (reason: unknown): Error => {
const message = reason instanceof Error ? reason.message : String(reason)
const failure = reason instanceof Error && message.startsWith('请求事务失败:') ? reason : error(message)
captureFailure = failure
if (!transactionAbort.signal.aborted) transactionAbort.abort(failure)
resolveCapture()
})
}) as typeof previousSend, restorers)
return failure
}
if (typeof navigator.sendBeacon === 'function') {
setMethod(navigator, 'sendBeacon', (function transactionBeacon(url: string | URL, data?: BodyInit | null) {
void bodyText(data).then((text) => capture({
boundary: 'beacon', method: 'POST', url: absoluteUrl(String(url)), headers: {}, bodyText: text,
})).catch((reason) => {
const capture = async (request: CapturedRequest): Promise<void> => {
if (captured || captureFailure) {
throw fail('页面流程产生了目标请求之外的额外网络请求')
}
if (prerequisiteInFlight || prerequisiteIndex !== input.transaction.prerequisites.length) {
throw fail('页面在在线前置请求完成前尝试生成最终业务请求')
}
if (!requestMatchesStep(input.transaction.request, request)) {
throw fail(`页面尝试访问未授权请求 ${request.method} ${request.url}`)
}
if (byteLength(request.bodyText) > MAX_BODY_BYTES) {
throw fail('页面生成的请求 Body 超过 8 MiB')
}
observeInput?.(capturedBody(request))
const missing = fields.filter((field) => !consumed.has(field.path))
if (missing.length) throw fail(`未证明新明文进入转换:${missing.map((field) => field.path || 'body').join('、')};页面可能仍在使用旧状态`)
captured = request
resolveCapture()
}
const previousFetch = window.fetch
setMethod(window, 'fetch', (async function transactionFetch(this: Window, requestInput: RequestInfo | URL, init?: RequestInit) {
const request = new Request(resolveRequestTransactionFetchInput(requestInput), init)
const body = await readRequestBody(request, MAX_BODY_BYTES)
const observed: CapturedRequest = {
boundary: 'fetch',
method: request.method.toUpperCase(),
url: request.url,
headers: { ...headerRecord(request.headers), 'content-type': body.contentType },
bodyText: body.text,
}
const prerequisite = input.transaction.prerequisites[prerequisiteIndex]
if (prerequisite) {
if (prerequisiteInFlight) throw fail('页面并发发起了多个在线前置请求,无法证明执行顺序')
if (!requestMatchesStep(prerequisite, observed)) {
throw fail(`页面尝试访问未授权请求 ${observed.method} ${observed.url}`)
}
let forwardAbort: (() => void) | undefined
try {
validatePrerequisiteRequest(prerequisite, observed)
prerequisiteInFlight = true
if (request.signal.aborted) transactionAbort.abort(request.signal.reason)
else {
forwardAbort = () => transactionAbort.abort(request.signal.reason)
request.signal.addEventListener('abort', forwardAbort, { once: true })
}
const expectedRedirect = comparableUrl(prerequisite.url, runtimeBaseUrl())
=== comparableUrl(prerequisite.response.url, runtimeBaseUrl()) ? 'error' : 'follow'
const guardedRequest = new Request(request, {
redirect: expectedRedirect,
signal: transactionAbort.signal,
})
const response = await Reflect.apply(previousFetch, this, [guardedRequest])
await validatePrerequisiteResponse(prerequisite, response)
prerequisiteIndex += 1
return response
} catch (reason) {
throw fail(reason)
} finally {
if (forwardAbort) request.signal.removeEventListener('abort', forwardAbort)
prerequisiteInFlight = false
}
}
await capture(observed)
return await new Promise<Response>(() => undefined)
}) as typeof previousFetch, restorers)
const xhrMetadata = new WeakMap<XMLHttpRequest, { method: string; url: string; headers: Record<string, string> }>()
const xhrPrototype = XMLHttpRequest.prototype
const previousOpen = xhrPrototype.open
const previousSetHeader = xhrPrototype.setRequestHeader
const previousSend = xhrPrototype.send
setMethod(xhrPrototype, 'open', (function transactionOpen(this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
xhrMetadata.set(this, { method: method.toUpperCase(), url: absoluteUrl(String(url)), headers: Object.create(null) as Record<string, string> })
return Reflect.apply(previousOpen, this, [method, url, ...rest] as never)
}) as typeof previousOpen, restorers)
setMethod(xhrPrototype, 'setRequestHeader', (function transactionSetHeader(this: XMLHttpRequest, name: string, value: string) {
const metadata = xhrMetadata.get(this)
if (metadata) metadata.headers[name.toLowerCase()] = value
return Reflect.apply(previousSetHeader, this, [name, value])
}) as typeof previousSetHeader, restorers)
setMethod(xhrPrototype, 'send', (function transactionSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
const metadata = xhrMetadata.get(this)
if (!metadata) throw error('XHR 没有可验证的 open 边界')
void bodyText(body).then((text) => capture({ boundary: 'xhr', ...metadata,
headers: normalizedBodyHeaders(body, metadata.headers), bodyText: text })).catch((reason) => {
captureFailure = reason instanceof Error ? reason : error(String(reason))
resolveCapture()
})
return true
}) as typeof navigator.sendBeacon, restorers)
}
}) as typeof previousSend, restorers)
const formPrototype = HTMLFormElement.prototype
const previousSubmit = formPrototype.submit
const previousRequestSubmit = formPrototype.requestSubmit
setMethod(formPrototype, 'submit', (function transactionSubmit(this: HTMLFormElement) {
void capture(formRequest(this)).catch(() => undefined)
}) as typeof previousSubmit, restorers)
setMethod(formPrototype, 'requestSubmit', (function transactionRequestSubmit(this: HTMLFormElement, submitter?: HTMLElement | null) {
void capture(formRequest(this, submitter)).catch(() => undefined)
}) as typeof previousRequestSubmit, restorers)
const submitListener = (event: SubmitEvent) => {
event.preventDefault()
event.stopImmediatePropagation()
if (event.target instanceof HTMLFormElement) void capture(formRequest(event.target, event.submitter)).catch(() => undefined)
}
document.addEventListener('submit', submitListener, true)
restorers.push(() => document.removeEventListener('submit', submitListener, true))
if (typeof navigator.sendBeacon === 'function') {
setMethod(navigator, 'sendBeacon', (function transactionBeacon(url: string | URL, data?: BodyInit | null) {
void bodyText(data).then((text) => capture({
boundary: 'beacon', method: 'POST', url: absoluteUrl(String(url)), headers: normalizedBodyHeaders(data, {}), bodyText: text,
})).catch((reason) => {
captureFailure = reason instanceof Error ? reason : error(String(reason))
resolveCapture()
})
return true
}) as typeof navigator.sendBeacon, restorers)
}
setMethod(window, 'alert', (() => undefined) as typeof window.alert, restorers)
setMethod(window, 'confirm', (() => false) as typeof window.confirm, restorers)
setMethod(window, 'prompt', (() => null) as typeof window.prompt, restorers)
setMethod(window, 'open', (() => null) as typeof window.open, restorers)
const formPrototype = HTMLFormElement.prototype
const previousSubmit = formPrototype.submit
const previousRequestSubmit = formPrototype.requestSubmit
setMethod(formPrototype, 'submit', (function transactionSubmit(this: HTMLFormElement) {
void capture(formRequest(this)).catch(() => undefined)
}) as typeof previousSubmit, restorers)
setMethod(formPrototype, 'requestSubmit', (function transactionRequestSubmit(this: HTMLFormElement, submitter?: HTMLElement | null) {
void capture(formRequest(this, submitter)).catch(() => undefined)
}) as typeof previousRequestSubmit, restorers)
const submitListener = (event: SubmitEvent) => {
event.preventDefault()
event.stopImmediatePropagation()
if (event.target instanceof HTMLFormElement) void capture(formRequest(event.target, event.submitter)).catch(() => undefined)
}
document.addEventListener('submit', submitListener, true)
restorers.push(() => document.removeEventListener('submit', submitListener, true))
let invocationFailure: unknown
let returned: unknown
try {
setMethod(window, 'alert', (() => undefined) as typeof window.alert, restorers)
setMethod(window, 'confirm', (() => false) as typeof window.confirm, restorers)
setMethod(window, 'prompt', (() => null) as typeof window.prompt, restorers)
setMethod(window, 'open', (() => null) as typeof window.open, restorers)
let invocationFailure: unknown
let returned: unknown
const domInputCount = bindLogicalInput(input.logicalInput)
if (domInputCount) await delay(0)
try { returned = input.invoke({ domInputCount }) } catch (reason) {
invocationFailure = reason
resolveCapture()
@@ -570,39 +640,47 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
return value
} finally {
if (!transactionAbort.signal.aborted) transactionAbort.abort(error('请求事务已经结束'))
for (const restore of restorers.reverse()) {
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
try { rollback.finish() } finally {
observeInput = undefined
for (const restore of [restoreObservation, cancelTasks, ...restorers.reverse()]) {
try { restore?.() } catch { /* The document may have been replaced while fail-closing. */ }
}
}
rollback.finish()
}
}
export async function executeSideEffectFreeCallable(
export function executeSideEffectFreeCallable(
invoke: () => unknown,
execution: BrowserPageCallableExecutionPolicy,
): Promise<unknown> {
return serializePageExecution(() => runSideEffectFreeCallable(invoke, execution))
}
async function runSideEffectFreeCallable(invoke: () => unknown, execution: BrowserPageCallableExecutionPolicy): Promise<unknown> {
const rollback = beginDomRollback()
let cancelTasks: (() => void) | undefined
const restorers: Array<() => void> = []
let attemptedBoundary = ''
const block = (boundary: string): never => {
attemptedBoundary = boundary
throw error(`普通页面函数尝试触发 ${boundary},必须改用请求事务`)
}
setMethod(window, 'fetch', (() => block('Fetch')) as typeof window.fetch, restorers)
setMethod(XMLHttpRequest.prototype, 'send', (function blockedXhrSend() { return block('XHR') }) as typeof XMLHttpRequest.prototype.send, restorers)
if (typeof navigator.sendBeacon === 'function') {
setMethod(navigator, 'sendBeacon', (() => block('Beacon')) as typeof navigator.sendBeacon, restorers)
}
setMethod(HTMLFormElement.prototype, 'submit', (function blockedSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.submit, restorers)
setMethod(HTMLFormElement.prototype, 'requestSubmit', (function blockedRequestSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.requestSubmit, restorers)
const submitListener = (event: SubmitEvent) => {
event.preventDefault()
event.stopImmediatePropagation()
attemptedBoundary = 'Form Submit'
}
document.addEventListener('submit', submitListener, true)
restorers.push(() => document.removeEventListener('submit', submitListener, true))
try {
cancelTasks = trackInvocationTasks()
let attemptedBoundary = ''
const block = (boundary: string): never => {
attemptedBoundary = boundary
throw error(`普通页面函数尝试触发 ${boundary},必须改用请求事务`)
}
setMethod(window, 'fetch', (() => block('Fetch')) as typeof window.fetch, restorers)
setMethod(XMLHttpRequest.prototype, 'send', (function blockedXhrSend() { return block('XHR') }) as typeof XMLHttpRequest.prototype.send, restorers)
if (typeof navigator.sendBeacon === 'function') {
setMethod(navigator, 'sendBeacon', (() => block('Beacon')) as typeof navigator.sendBeacon, restorers)
}
setMethod(HTMLFormElement.prototype, 'submit', (function blockedSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.submit, restorers)
setMethod(HTMLFormElement.prototype, 'requestSubmit', (function blockedRequestSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.requestSubmit, restorers)
const submitListener = (event: SubmitEvent) => {
event.preventDefault()
event.stopImmediatePropagation()
attemptedBoundary = 'Form Submit'
}
document.addEventListener('submit', submitListener, true)
restorers.push(() => document.removeEventListener('submit', submitListener, true))
const value = await settleCallableResult(invoke(), execution)
await Promise.resolve()
if (attemptedBoundary) throw error(`普通页面函数尝试触发 ${attemptedBoundary},必须改用请求事务`)
@@ -610,9 +688,42 @@ export async function executeSideEffectFreeCallable(
if (mutationCount) throw error('普通页面函数修改了页面 DOM,必须改用请求事务')
return value
} finally {
for (const restore of restorers.reverse()) {
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
try { rollback.finish() } finally {
cancelTasks?.()
for (const restore of restorers.reverse()) {
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
}
}
rollback.finish()
}
}
function trackInvocationTasks(): () => void {
const restorers: Array<() => void> = []
const pending: Array<() => void> = []
let active = true
const cleanup = () => {
active = false
for (const action of [...pending, ...restorers.reverse()]) {
try { action() } catch { /* Cleanup must attempt every installed hook. */ }
}
}
try {
for (const [schedule, cancel] of [
['setTimeout', 'clearTimeout'], ['setInterval', 'clearInterval'],
['requestAnimationFrame', 'cancelAnimationFrame'], ['requestIdleCallback', 'cancelIdleCallback'],
] as const) {
const original = window[schedule] as Function | undefined
const clear = window[cancel] as Function | undefined
if (!original || !clear) continue
setMethod(window, schedule, ((callback: unknown, ...args: unknown[]) => {
if (typeof callback !== 'function') throw error('回放不支持字符串定时任务')
const id = Reflect.apply(original, window, [(...values: unknown[]) => {
if (active) Reflect.apply(callback, window, values)
}, ...args])
pending.push(() => Reflect.apply(clear, window, [id]))
return id
}) as never, restorers)
}
} catch (reason) { cleanup(); throw reason }
return cleanup
}
+30 -1
View File
@@ -13,6 +13,7 @@ import { resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targe
import { PAGE_RECORDER_PROTOCOL_VERSION, PAGE_RECORDER_REGISTRY_KEY } from '@/features/browser-recording/constants';
import { executeFirefoxPageRecorderCommand } from '@/features/browser-recording/bridge-client';
import { normalizeBrowserRecordingCrypto } from '@/features/browser-crypto/model';
import { serializeTabExecution, withPageNetworkGuard } from './network-guard';
import {
MAX_CALLABLE_TIMEOUT_MS,
MIN_CALLABLE_TIMEOUT_MS,
@@ -241,13 +242,39 @@ async function pageCallableCommand(
if (command === 'callable.list') return [];
throw new Error('页面函数控制器不存在,页面可能已经刷新');
}
return await Promise.resolve(controller.command(command, input));
try {
return await Promise.resolve(controller.command(command, input));
} catch (error) {
return { __yakitCallError: {
message: error instanceof Error ? error.message : String(error),
} };
}
}
async function callPageController(
target: BrowserTarget,
command: PageControllerCommand,
input: Record<string, unknown> = {},
): Promise<unknown> {
if (command === 'callable.execute' || command === 'transform.execute') {
return serializeTabExecution(target.tabId, async () => {
const current = await resolveDocumentTarget(target);
const callables = await callPageController(current, 'callable.list') as RawCallable[];
const ids = command === 'callable.execute' ? [input.callableId]
: (input.direction as BrowserTransformDirection).nodes.flatMap((node) => node.kind === 'page.call' ? [node.callableId] : []);
const used = callables.filter((callable) => ids.includes(callable.id));
if (used.length !== new Set(ids).size) throw new ExtensionError('callable_unavailable', '页面函数已经失效');
const prerequisites = used.flatMap((callable) => callable.transaction?.prerequisites || []);
return withPageNetworkGuard(current, prerequisites, () => invokePageController(current, command, input));
});
}
return invokePageController(target, command, input);
}
async function invokePageController(
target: BrowserTarget,
command: PageControllerCommand,
input: Record<string, unknown>,
): Promise<unknown> {
if (import.meta.env.FIREFOX) return executeFirefoxPageRecorderCommand(target, command, input);
const [result] = await browser.scripting.executeScript({
@@ -260,6 +287,8 @@ async function callPageController(
if (injectionError !== undefined) {
throw new ExtensionError('page_callable_execution_failed', injectionErrorMessage(injectionError));
}
const failure = (result?.result as { __yakitCallError?: { message: string } } | undefined)?.__yakitCallError;
if (failure) throw new ExtensionError('page_callable_execution_failed', failure.message);
return result?.result;
}
+88
View File
@@ -0,0 +1,88 @@
import { browser } from 'wxt/browser';
import { scriptingTarget } from '@/platform/browser/targets';
import { ExtensionError } from '@/shared/errors';
import type { BrowserTarget, PageDialog } from '@/types/models';
export function installPageDialogCapture(): boolean {
const key = Symbol.for('com.yaklang.browser.page-dialogs.v1');
const existing = Reflect.get(globalThis, key) as { messages?: unknown[] } | undefined;
if (existing?.messages) return false;
const messages: PageDialog[] = [];
const original = {
alert: globalThis.alert,
confirm: globalThis.confirm,
prompt: globalThis.prompt,
};
const record = (type: PageDialog['type'], value: unknown, decision: PageDialog['decision']): void => {
if (messages.length >= 20) return;
let message = '';
try { message = String(value ?? ''); } catch { message = '[unprintable]'; }
messages.push({ type, message: message.slice(0, 1_000), decision, timestamp: Date.now() });
};
const replacements = {
alert(value?: unknown) {
record('alert', value, 'auto_dismissed');
},
confirm(value?: unknown) {
record('confirm', value, 'auto_accepted');
return true;
},
prompt(value?: unknown, defaultValue?: string) {
record('prompt', value, 'auto_submitted');
return defaultValue || '';
},
};
try {
globalThis.alert = replacements.alert;
globalThis.confirm = replacements.confirm;
globalThis.prompt = replacements.prompt;
Reflect.set(globalThis, key, { messages, original, replacements });
return true;
} catch {
globalThis.alert = original.alert;
globalThis.confirm = original.confirm;
globalThis.prompt = original.prompt;
return false;
}
}
export function restorePageDialogCapture(): PageDialog[] {
const key = Symbol.for('com.yaklang.browser.page-dialogs.v1');
const capture = Reflect.get(globalThis, key) as {
messages?: PageDialog[];
original?: { alert: typeof globalThis.alert; confirm: typeof globalThis.confirm; prompt: typeof globalThis.prompt };
replacements?: { alert: typeof globalThis.alert; confirm: typeof globalThis.confirm; prompt: typeof globalThis.prompt };
} | undefined;
if (!capture?.original || !capture.replacements) return [];
if (globalThis.alert === capture.replacements.alert) globalThis.alert = capture.original.alert;
if (globalThis.confirm === capture.replacements.confirm) globalThis.confirm = capture.original.confirm;
if (globalThis.prompt === capture.replacements.prompt) globalThis.prompt = capture.original.prompt;
Reflect.deleteProperty(globalThis, key);
return Array.isArray(capture.messages) ? capture.messages.slice(0, 20) : [];
}
export async function beginPageDialogCapture(target: BrowserTarget): Promise<boolean> {
const result = await browser.scripting.executeScript({
target: scriptingTarget(target),
world: 'MAIN',
func: installPageDialogCapture,
});
if (result.length !== 1 || typeof result[0].result !== 'boolean') {
throw new ExtensionError('dialog_capture_unavailable', '无法安全处理页面弹窗,未执行页面操作');
}
return result[0].result;
}
export async function endPageDialogCapture(target: BrowserTarget, owned: boolean): Promise<PageDialog[]> {
if (!owned) return [];
try {
const result = await browser.scripting.executeScript({
target: scriptingTarget(target),
world: 'MAIN',
func: restorePageDialogCapture,
});
return Array.isArray(result[0]?.result) ? result[0].result as PageDialog[] : [];
} catch {
return [];
}
}
+20 -4
View File
@@ -2,7 +2,7 @@ import { browser } from 'wxt/browser';
import type {
ActiveTabInfo, BrowserStorageInventory, BrowserTarget, PageAuthenticationSignals, PageContext, PageContextChange,
PageContextDiff, PageContextOptions, PageEvalResult, PageNodeAction, PageNodeActionResult,
PageFormSummary, PageNodeDetails, PageNodeSummary, PageStorageSummary,
PageDialog, PageFormSummary, PageNodeDetails, PageNodeSummary, PageStorageSummary,
} from '@/types/models';
import { executePageOperation } from '@/features/page-context/execution-adapter';
import { getFrameInventory } from '@/features/page-context/frames';
@@ -12,6 +12,7 @@ import { listCookies } from '@/features/cookies/service';
import { ExtensionError } from '@/shared/errors';
import { getTab, resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets';
import { resolveTabCookieStoreId } from '@/platform/browser/isolation';
import { beginPageDialogCapture, endPageDialogCapture } from './dialogs';
async function collectDocumentContext(input: { options: PageContextOptions; captureId: string }) {
const MAX_SCANNED_ELEMENTS = 10_000;
@@ -738,7 +739,13 @@ function operateRegisteredNode(input: { captureId: string; nodeId: string; opera
if (input.operation === 'inspect') return { ok: true as const, node };
const control = element as HTMLInputElement;
if (input.operation === 'click') {
if (control.disabled || element.getAttribute('aria-disabled') === 'true') {
const style = getComputedStyle(element);
const visible = element.getClientRects().length > 0
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.opacity !== '0'
&& style.pointerEvents !== 'none';
if (!visible || control.disabled || element.getAttribute('aria-disabled') === 'true') {
return { ok: false as const, code: 'node_not_actionable', message: '页面元素当前不可点击' };
}
const click = (element as HTMLElement).click;
@@ -807,8 +814,17 @@ export async function actOnPageNode(
input: BrowserTarget | number,
value?: string,
): Promise<PageNodeActionResult> {
const node = await operateNode(captureId, nodeId, action, input, value);
return { action, completedAt: Date.now(), node };
const target = await resolveDocumentTarget(input);
const dialogCaptureOwned = action === 'click' ? await beginPageDialogCapture(target) : false;
let node: PageNodeDetails;
let dialogs: PageDialog[] = [];
try {
node = await operateNode(captureId, nodeId, action, target, value);
if (dialogCaptureOwned) await new Promise((resolve) => globalThis.setTimeout(resolve, 50));
} finally {
dialogs = await endPageDialogCapture(target, dialogCaptureOwned);
}
return { action, status: 'dispatched', dispatchedAt: Date.now(), node, dialogs };
}
export async function invokePageFunction(path: string, args: unknown[], input?: BrowserTarget | number, timeoutMs = 10_000): Promise<PageEvalResult> {
+33 -2
View File
@@ -7,6 +7,7 @@ const harness = vi.hoisted(() => ({
state: undefined as ExtensionState | undefined,
sourceRules: new Map<string, NormalizedProxyRule[]>(),
proxySet: vi.fn(async (_details: unknown) => undefined),
proxyClear: vi.fn(async (_details: unknown) => undefined),
proxyGet: vi.fn(async (_details: unknown) => ({
value: { mode: 'direct' }, levelOfControl: 'controllable_by_this_extension',
})),
@@ -24,7 +25,7 @@ const harness = vi.hoisted(() => ({
vi.mock('wxt/browser', () => ({
browser: {
proxy: { settings: { get: harness.proxyGet, set: harness.proxySet } },
proxy: { settings: { get: harness.proxyGet, set: harness.proxySet, clear: harness.proxyClear } },
storage: {
session: { get: harness.sessionGet, set: harness.sessionSet },
onChanged: { addListener: vi.fn() },
@@ -60,6 +61,7 @@ vi.mock('./repository', () => ({
import {
applyProxyRules, importProxyConfiguration, refreshProxyRuleSource, removeProxyProfile,
routeCurrentSite, saveProxyProfile, saveProxyRuleSource, setProxyAuthPassword, switchProxy,
getProxyStatus, releaseProxy, proxyConfigMatches,
} from './service';
function baseState(): ExtensionState {
@@ -120,12 +122,41 @@ describe('proxy service', () => {
harness.state = baseState();
harness.sourceRules.clear();
vi.clearAllMocks();
harness.proxySet.mockResolvedValue(undefined);
harness.proxySet.mockImplementation(async (details) => {
harness.proxyGet.mockResolvedValue({ value: (details as any).value, levelOfControl: 'controlled_by_this_extension' });
});
harness.proxyGet.mockResolvedValue({
value: { mode: 'direct' }, levelOfControl: 'controllable_by_this_extension',
});
});
it('distinguishes launch proxy from stored selection, verifies application and releases without selecting direct', async () => {
harness.proxyGet.mockResolvedValue({ value: { mode: 'fixed_servers', rules: { singleProxy: { host: '127.0.0.1', port: 8083 } } } as any, levelOfControl: 'controllable_by_this_extension' });
expect(await getProxyStatus()).toEqual({ control: 'controllable_by_this_extension', label: 'http://127.0.0.1:8083', activeProfileId: undefined, followingStartup: true });
harness.state!.startupProxy = 'http://127.0.0.1:9999';
expect((await getProxyStatus()).followingStartup).toBe(false);
harness.state!.startupProxy = 'http://127.0.0.1:8083';
expect((await getProxyStatus()).followingStartup).toBe(true);
await saveProxyProfile({ ...baseState().proxyProfiles[0] });
expect(harness.proxySet).not.toHaveBeenCalled();
await switchProxy('custom');
expect((await getProxyStatus()).activeProfileId).toBe('custom');
harness.proxyClear.mockImplementation(async () => {
harness.proxyGet.mockResolvedValue({ value: { mode: 'fixed_servers' }, levelOfControl: 'controllable_by_this_extension' });
});
expect((await releaseProxy()).activeProxyId).toBe('');
expect(harness.proxyClear).toHaveBeenCalledWith({ scope: 'regular' });
expect(harness.proxySet).toHaveBeenCalledTimes(1);
harness.proxySet.mockResolvedValue(undefined);
await expect(switchProxy('direct')).rejects.toThrow('实际配置或控制权');
expect(harness.state?.activeProxyId).toBe('');
harness.state!.activeProxyId = 'custom';
expect((await removeProxyProfile('custom')).activeProxyId).toBe('');
harness.proxyGet.mockResolvedValue({ value: { mode: 'direct' }, levelOfControl: 'not_controllable' });
await expect(switchProxy('direct')).rejects.toThrow('管理策略');
expect(proxyConfigMatches({ mode: 'fixed_servers', rules: { singleProxy: { host: 'a', port: 80 } } }, { mode: 'fixed_servers', rules: { singleProxy: { scheme: 'http', host: 'a', port: 80 }, bypassList: [] } })).toBe(true);
});
it('applies automatic routing and commits the exact PAC revision', async () => {
const rules: NormalizedProxyRule[] = Array.from({ length: 2_000 }, (_, ordinal) => ({
sourceId: 'source', ordinal, condition: { type: 'host_wildcard', value: `*.d${ordinal}.example` },
+81 -5
View File
@@ -2,7 +2,7 @@ import { browser } from 'wxt/browser';
import { isStateStorageChange, PROXY_AUTH_STORAGE_KEY } from '@/protocol/storage';
import type {
ExtensionState, ProxyConfiguration, ProxyProfile, ProxyRule, ProxyRulePage, ProxyRulePreview,
ProxyRuleSource, ProxyRuleSourceExport, ProxyRuleSourceInput,
ProxyRuleSource, ProxyRuleSourceExport, ProxyRuleSourceInput, ProxyStatus,
} from '@/types/models';
import { getState, updateState } from '@/platform/storage/state';
import {
@@ -32,6 +32,10 @@ const authPasswords = new Map<string, string>();
const sourceRefreshes = new Map<string, { identity: string; promise: Promise<ExtensionState> }>();
let proxyState: ExtensionState | undefined;
browser.proxy?.settings?.onChange?.addListener(() => {
void browser.runtime.sendMessage({ action: 'proxy.status.changed' }).catch(() => undefined);
});
function isFirefox(): boolean {
return Boolean(import.meta.env.FIREFOX);
}
@@ -119,7 +123,7 @@ async function assertProxyControl(): Promise<void> {
throw new Error('浏览器代理正由其他扩展控制,请先停用其他代理扩展后重试');
}
if (current.levelOfControl === 'not_controllable') {
throw new Error('浏览器代理受系统策略或启动参数控制,当前扩展无法修改');
throw new Error('浏览器报告代理不可由扩展控制,请检查强制管理策略;普通启动代理参数不代表锁定');
}
}
@@ -130,18 +134,89 @@ async function setPacScript(pacScript: string): Promise<void> {
value: { proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(pacScript)}` } as unknown as Browser.proxy.ProxyConfig,
scope: 'regular',
});
await verifyProxyControl({ proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(pacScript)}` });
return;
}
await browser.proxy.settings.set({
value: { mode: 'pac_script', pacScript: { data: pacScript, mandatory: true } },
scope: 'regular',
});
await verifyProxyControl({ mode: 'pac_script', pacScript: { data: pacScript, mandatory: true } });
}
async function setBrowserProxyProfile(profile: ProxyProfile): Promise<void> {
await assertProxyControl();
const value = isFirefox() ? firefoxProxyValue(profile) : chromeProxyValue(profile);
await browser.proxy.settings.set({ value: value as Browser.proxy.ProxyConfig, scope: 'regular' });
await verifyProxyControl(value);
}
async function verifyProxyControl(expected: object): Promise<void> {
const actual = await browser.proxy.settings.get({ incognito: false });
if (actual.levelOfControl !== 'controlled_by_this_extension' || !proxyConfigMatches(actual.value, expected)) {
throw new Error('代理设置已提交,但实际配置或控制权与预期不符,请刷新实际代理状态后重试');
}
}
export async function getProxyStatus(state?: ExtensionState): Promise<ProxyStatus> {
if (!browser.proxy?.settings) return { control: 'unavailable', label: '浏览器不支持代理 API' };
const actual = await browser.proxy.settings.get({ incognito: false });
const value = actual.value as Browser.proxy.ProxyConfig & { proxyType?: string };
const control = actual.levelOfControl;
const mode = value.mode || value.proxyType;
const server = value.rules?.singleProxy;
let label = server ? `${server.scheme || 'http'}://${server.host}:${server.port || (server.scheme === 'https' ? 443 : server.scheme?.startsWith('socks') ? 1080 : 80)}`
: ({ direct: '直接连接', none: '直接连接', system: '系统代理', pac_script: 'PAC 自动代理', autoConfig: 'PAC 自动代理', fixed_servers: '固定代理(按协议)', manual: '手动代理', auto_detect: '自动检测' }[mode] || '未知代理模式');
let activeProfileId: string | undefined;
const current = state || await getState();
let followingStartup = control === 'controllable_by_this_extension';
if (followingStartup && current.startupProxy) {
try {
const endpoint = current.startupProxy === 'direct' ? undefined : new URL(current.startupProxy);
followingStartup = proxyConfigMatches(value, endpoint ? chromeProxyValue({
id: '', name: '', kind: 'fixed_servers', scheme: endpoint.protocol === 'https:' ? 'https' : 'http',
host: endpoint.hostname, port: Number(endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80)), bypass: [],
}) : { mode: 'direct' });
} catch { followingStartup = false; }
}
if (control === 'controlled_by_this_extension') {
const profile = current.proxyProfiles.find((item) => item.id === current.activeProxyId);
if (profile && proxyConfigMatches(value, isFirefox() ? firefoxProxyValue(profile) : chromeProxyValue(profile))) {
activeProfileId = profile.id;
} else if (current.activeProxyId === 'auto') {
const artifact = current.proxyRuntime.revision ? await getCompiledArtifact(current.proxyRuntime.revision) : undefined;
if (artifact && (value.pacScript?.data === artifact.pacScript
|| (value as unknown as { autoConfigUrl?: string }).autoConfigUrl === `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(artifact.pacScript)}`)) activeProfileId = 'auto';
}
if (activeProfileId) label = activeProfileId === 'auto' ? '自动切换(PAC' : profile!.name === label ? label : `${profile!.name} · ${label}`;
}
return { control, label, activeProfileId, followingStartup };
}
// Chrome may add default ports and expand singleProxy into per-protocol entries on readback.
export function proxyConfigMatches(actual: unknown, expected: unknown): boolean {
const a = actual as Record<string, any>;
const e = expected as Record<string, any>;
if (e.mode === 'fixed_servers') {
if (a.mode !== e.mode) return false;
const server = (value: any) => value && `${value.scheme || 'http'}://${String(value.host).toLowerCase()}:${value.port || (value.scheme === 'https' ? 443 : value.scheme?.startsWith('socks') ? 1080 : 80)}`;
const wanted = server(e.rules.singleProxy);
const rules = a.rules || {};
const matches = rules.singleProxy ? server(rules.singleProxy) === wanted
: ['proxyForHttp', 'proxyForHttps', 'proxyForFtp', 'fallbackProxy'].every((key) => server(rules[key]) === wanted);
return matches && JSON.stringify([...(rules.bypassList || [])].sort()) === JSON.stringify([...(e.rules.bypassList || [])].sort());
}
if (e.mode === 'pac_script') return a.mode === e.mode && a.pacScript?.data === e.pacScript?.data && a.pacScript?.url === e.pacScript?.url;
return Object.keys(e).every((key) => a[key] === e[key]);
}
export async function releaseProxy(): Promise<ExtensionState> {
return updateState(async (current) => {
await browser.proxy.settings.clear({ scope: 'regular' });
const actual = await browser.proxy.settings.get({ incognito: false });
if (actual.levelOfControl === 'controlled_by_this_extension') throw new Error('浏览器尚未撤销本扩展的代理接管');
return { ...current, activeProxyId: '' };
});
}
async function compilationInput(state: ExtensionState, withRules = true): Promise<ProxyCompilationInput> {
@@ -213,17 +288,17 @@ export async function saveProxyProfile(profile: ProxyProfile): Promise<Extension
...current,
proxyProfiles: [...current.proxyProfiles.filter((item) => item.id !== canonical.id), canonical],
});
if (current.activeProxyId === canonical.id) await setBrowserProxyProfile(canonical);
if ((await getProxyStatus(current)).activeProfileId === canonical.id) await setBrowserProxyProfile(canonical);
return next;
});
}
export async function removeProxyProfile(profileId: string): Promise<ExtensionState> {
const saved = await updateState((current) => {
const saved = await updateState(async (current) => {
const profile = current.proxyProfiles.find((item) => item.id === profileId);
if (!profile) throw new Error('代理配置不存在');
if (RESERVED_PROXY_PROFILE_IDS.has(profileId) || profile.builtin) throw new Error('内置代理出口不能删除');
if (current.activeProxyId === profileId) throw new Error('该出口正在使用,请先切换到其他出口');
if ((await getProxyStatus(current)).activeProfileId === profileId) throw new Error('该出口正在使用,请先切换到其他出口');
if (current.proxyRules.some((rule) => rule.proxyProfileId === profileId)
|| current.proxyRuleSources.some((source) => source.matchProfileId === profileId || source.bypassProfileId === profileId)
|| current.proxyRouting.defaultProfileId === profileId) {
@@ -232,6 +307,7 @@ export async function removeProxyProfile(profileId: string): Promise<ExtensionSt
return dirtyProxyState({
...current,
proxyProfiles: current.proxyProfiles.filter((item) => item.id !== profileId),
activeProxyId: current.activeProxyId === profileId ? '' : current.activeProxyId,
});
});
await setProxyAuthPassword(profileId, '');
+3 -1
View File
@@ -10,6 +10,7 @@ import { request } from '@/platform/messaging/runtime';
import type { ProxyConditionType, ProxyRule, ProxyRulePreview } from '@/types/models';
import { CONDITION_LABELS, formatBytes, proxyProfileDetail } from './presentation';
import type { ProxyViewProps } from './types';
import { useProxyStatus } from './ProxyStatusBar';
import './proxy-workspace.css';
const ROW_HEIGHT = 58;
@@ -47,6 +48,7 @@ function conditionHint(type: ProxyConditionType): string {
}
export function AutoSwitchView({ state, setState, run, busy, tab }: ProxyViewProps) {
const proxyStatus = useProxyStatus(state);
const rules = useMemo(() => [...state.proxyRules].sort((left, right) => left.order - right.order), [state.proxyRules]);
const routableProfiles = useMemo(() => state.proxyProfiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)), [state.proxyProfiles]);
const [draft, setDraft] = useState<ProxyRule>(() => freshRule(state.proxyRules.length, tab?.url));
@@ -64,7 +66,7 @@ export function AutoSwitchView({ state, setState, run, busy, tab }: ProxyViewPro
const visibleCount = Math.ceil(LIST_HEIGHT / ROW_HEIGHT) + OVERSCAN * 2;
const visibleRules = rules.slice(firstVisible, firstVisible + visibleCount);
const enabledSources = state.proxyRuleSources.filter((source) => source.enabled && source.revision);
const active = state.activeProxyId === 'auto';
const active = proxyStatus.activeProfileId === 'auto';
const save = () => run(async () => {
const now = Date.now();
+10 -6
View File
@@ -6,9 +6,10 @@ import { Field } from '@/components/ui/field';
import { Switch } from '@/components/ui/switch';
import { request } from '@/platform/messaging/runtime';
import type { ProxyProfile } from '@/types/models';
import { PROXY_KIND_LABELS, proxyProfileDetail } from './presentation';
import { normalizeBypass, PROXY_KIND_LABELS, proxyProfileDetail } from './presentation';
import type { ProxyViewProps } from './types';
import './proxy-workspace.css';
import { ProxyStatusBar, StartupProxyOption, useProxyStatus } from './ProxyStatusBar';
function createProfile(): ProxyProfile {
return {
@@ -17,6 +18,7 @@ function createProfile(): ProxyProfile {
}
export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps) {
const status = useProxyStatus(state);
const [draft, setDraft] = useState<ProxyProfile>(() => state.proxyProfiles[0] || createProfile());
const [password, setPassword] = useState('');
const [passwordConfigured, setPasswordConfigured] = useState(false);
@@ -32,7 +34,7 @@ export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps
}, [draft.id]);
const persistDraft = async () => {
const saved = await request('proxy.save', draft);
const saved = await request('proxy.save', { ...draft, bypass: normalizeBypass(draft.bypass) });
setState(saved);
if (draft.authEnabled) {
if (password) await request('proxy.auth.set', { profileId: draft.id, password });
@@ -65,18 +67,20 @@ export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps
<Button variant="primary" onClick={() => setDraft(createProfile())}><Plus size={16} /></Button>
</div>
<ProxyStatusBar status={status} />
<div className="proxy-profile-workspace">
<section className="proxy-profile-index" aria-label="代理出口列表">
<div className="proxy-panel-label"><span></span><strong>{state.proxyProfiles.length}</strong></div>
<div className="proxy-profile-list">
<StartupProxyOption state={state} status={status} setState={setState} run={run} busy={busy} />
{state.proxyProfiles.map((profile) => <button
key={profile.id}
className={`${draft.id === profile.id ? 'is-selected' : ''} ${state.activeProxyId === profile.id ? 'is-active' : ''}`}
className={`${draft.id === profile.id ? 'is-selected' : ''} ${status.activeProfileId === profile.id ? 'is-active' : ''}`}
onClick={() => setDraft({ ...profile, bypass: [...profile.bypass] })}
>
<span className="proxy-profile-icon"><Network size={16} /></span>
<span><strong>{profile.name}</strong><small>{proxyProfileDetail(profile)}</small></span>
{state.activeProxyId === profile.id && <i>使</i>}
{status.activeProfileId === profile.id && <i>使</i>}
<ChevronRight size={15} />
</button>)}
</div>
@@ -85,7 +89,7 @@ export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps
<section className="proxy-profile-editor">
<div className="proxy-editor-heading">
<div><span>{draft.builtin ? '内置出口' : '自定义出口'}</span><h2>{draft.name}</h2></div>
<span className={`proxy-live-state ${state.activeProxyId === draft.id ? 'is-live' : ''}`}><i />{state.activeProxyId === draft.id ? '当前生效' : '未使用'}</span>
<span className={`proxy-live-state ${status.activeProfileId === draft.id ? 'is-live' : ''}`}><i />{status.activeProfileId === draft.id ? '当前生效' : '未确认生效'}</span>
</div>
<div className="proxy-form-grid">
<Field label="名称"><input value={draft.name} disabled={draft.id === 'direct' || draft.id === 'system'} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
@@ -94,7 +98,7 @@ export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps
<Field label="协议"><select value={draft.scheme || 'http'} onChange={(event) => setDraft({ ...draft, scheme: event.target.value as ProxyProfile['scheme'] })}><option value="http">HTTP</option><option value="https">HTTPS</option><option value="socks4">SOCKS4</option><option value="socks5">SOCKS5</option></select></Field>
<Field label="主机"><input value={draft.host || ''} onChange={(event) => setDraft({ ...draft, host: event.target.value })} /></Field>
<Field label="端口"><input type="number" min="1" max="65535" value={draft.port || ''} onChange={(event) => setDraft({ ...draft, port: Number(event.target.value) })} /></Field>
<Field label="绕过列表" hint="每行一个域名、IP 或 &lt;local&gt;"><textarea rows={5} value={draft.bypass.join('\n')} onChange={(event) => setDraft({ ...draft, bypass: event.target.value.split('\n').map((item) => item.trim()).filter(Boolean) })} /></Field>
<Field label="绕过列表" hint="每行一个域名、IP 或 &lt;local&gt;"><textarea rows={5} value={draft.bypass.join('\n')} onChange={(event) => setDraft({ ...draft, bypass: event.target.value.split(/\r?\n/) })} /></Field>
</>}
{draft.kind === 'pac_script' && <>
<Field label="PAC URL"><input value={draft.pacUrl || ''} onChange={(event) => setDraft({ ...draft, pacUrl: event.target.value, pacScript: '' })} placeholder="https://example.com/proxy.pac" /></Field>
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { browser } from 'wxt/browser';
import { request } from '@/platform/messaging/runtime';
import type { ExtensionState, ProxyStatus } from '@/types/models';
import type { ProxyViewProps } from './types';
import { Check, Info, Monitor } from 'lucide-react';
import { Tooltip, TooltipProvider } from '@/components/ui/tooltip';
import './proxy-status.css';
export function useProxyStatus(state: ExtensionState): ProxyStatus {
const [status, setStatus] = useState<ProxyStatus>({ control: 'loading', label: '正在读取实际代理…' });
useEffect(() => {
let revision = 0;
const refresh = async () => {
const current = ++revision;
try {
const value = await request('proxy.status');
if (current === revision) setStatus(value);
} catch {
if (current === revision) setStatus({ control: 'unavailable', label: '实际代理读取失败' });
}
};
const listener = (message: unknown) => {
if ((message as { action?: string })?.action === 'proxy.status.changed') void refresh();
};
browser.runtime.onMessage.addListener(listener);
void refresh();
return () => { revision++; browser.runtime.onMessage.removeListener(listener); };
}, [state]);
return status;
}
export function ProxyStatusBar({ status }: { status: ProxyStatus }) {
const source = status.control === 'controlled_by_this_extension' ? '本扩展控制'
: status.control === 'controlled_by_other_extensions' ? '其他扩展控制'
: status.control === 'not_controllable' ? '浏览器限制修改,请检查管理策略'
: status.control === 'controllable_by_this_extension' ? '非本扩展控制,可选择出口接管' : '状态未确认';
return <section className="proxy-effective-status" aria-label="实际代理状态" role="status" title={source}>
<small></small><strong title={`${status.label} · ${source}`}>{status.label}</strong>
</section>;
}
export function StartupProxyOption({ state, status, setState, run, busy }: Pick<ProxyViewProps, 'state' | 'setState' | 'run' | 'busy'> & { status: ProxyStatus }) {
if (state.bridge.managedInstance?.manager === 'ytray' && state.startupProxy === 'direct') return null;
const active = Boolean(status.followingStartup);
const manager = state.bridge.managedInstance?.manager === 'ytray' ? 'YTray' : state.bridge.managedInstance?.manager === 'yakit' ? 'Yakit' : '浏览器';
const detail = state.startupProxy ? `${manager} · ${state.startupProxy === 'direct' ? '启动时直连' : state.startupProxy}` : `${manager}启动参数或系统默认设置`;
return <div className={`startup-proxy-option ${active ? 'is-active' : ''}`}>
<button role="radio" aria-checked={active} disabled={busy || status.control === 'loading' || status.control === 'unavailable'} onClick={() => void run(async () => {
setState(await request('proxy.release'));
const actual = await request('proxy.status');
if (!actual.followingStartup) throw new Error(`未能切换到启动配置;实际代理:${actual.label}。请检查其他扩展或管理策略。`);
}, '已跟随启动配置')}>
<span className="startup-proxy-icon"><Monitor size={16} /></span><span className="startup-proxy-label"><strong></strong><small title={detail}>{detail}</small></span><span className="startup-proxy-check">{active && <Check size={14} />}</span>
</button>
<TooltipProvider><Tooltip label="切换后使用浏览器启动时的网络配置;未指定启动代理时,跟随浏览器默认设置。可随时切换到其他模式,已保存的出口和规则不变。若受其他扩展或管理策略影响,以顶部实际代理为准。" side="top">
<button className="startup-proxy-info" aria-label="解释跟随启动配置"><Info size={14} /></button>
</Tooltip></TooltipProvider>
</div>;
}
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { normalizeBypass } from './presentation';
describe('normalizeBypass', () => {
it('cleans blank lines only when the proxy profile is saved', () => {
expect(normalizeBypass([' localhost ', '', ' ', '<local>'])).toEqual(['localhost', '<local>']);
});
});
+4
View File
@@ -31,6 +31,10 @@ export function proxyProfileDetail(profile: ProxyProfile): string {
return PROXY_KIND_LABELS[profile.kind];
}
export function normalizeBypass(items: string[]): string[] {
return items.map((item) => item.trim()).filter(Boolean);
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes > 100 * 1024 ? 0 : 1)} KB`;
+22
View File
@@ -0,0 +1,22 @@
.proxy-effective-status { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border); flex-shrink: 0; min-width: 0; }
.proxy-effective-status small { color: var(--muted); font-size: var(--text-xs, 12px); flex-shrink: 0; }
.proxy-effective-status strong { font-size: var(--text-sm, 13px); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.popup-proxy-view > .proxy-effective-status { padding: 8px 12px; background: var(--surface); }
.popup-proxy-view.popup-tool-view { overflow-y: auto; }
.startup-proxy-option { position: relative; border: 1px solid transparent; border-radius: var(--radius-md); min-width: 0; }
.startup-proxy-option.is-active { border-color: color-mix(in srgb, var(--primary) 20%, var(--border)); background: var(--primary-soft); }
.startup-proxy-option.is-active > button:first-child { color: var(--primary-text); }
.startup-proxy-option > button { border: 0; background: transparent; color: var(--foreground); cursor: pointer; padding: 7px 8px; }
.startup-proxy-option > button:first-child { display: grid; grid-template-columns: 28px minmax(0, 1fr) 14px; align-items: center; gap: 8px; width: 100%; padding: 5px 8px; min-width: 0; text-align: left; min-height: 41px; }
.startup-proxy-label { min-width: 0; padding-right: 28px; }
.startup-proxy-icon { width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--muted-strong); }
.startup-proxy-option.is-active .startup-proxy-icon { border-color: color-mix(in srgb, var(--primary) 28%, var(--border)); color: var(--primary); }
.startup-proxy-check { display: grid; place-items: center; color: var(--primary); }
.startup-proxy-option strong, .startup-proxy-option small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.startup-proxy-option strong { font-size: var(--text-sm, 13px); font-weight: 600; line-height: 16px; }
.startup-proxy-option small { margin-top: 1px; font-size: var(--text-xs, 12px); line-height: 14px; color: var(--muted); }
.startup-proxy-option svg { flex-shrink: 0; }
.startup-proxy-option .startup-proxy-info { position: absolute; right: 30px; top: 50%; translate: 0 -50%; color: var(--muted); display: grid; place-items: center; width: 28px; height: 32px; padding: 0; }
.startup-proxy-option > button:hover { background: var(--surface-subtle); }
.startup-proxy-option > button:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; border-radius: var(--radius-md); }
.startup-proxy-option > button:disabled { cursor: default; opacity: .5; }
+11 -3
View File
@@ -31,7 +31,7 @@ export const DEFAULT_STATE: ExtensionState = {
proxyRuleSources: [],
proxyRouting: { defaultProfileId: 'direct', failMode: 'closed' },
proxyRuntime: { dirty: false, compiledBytes: 0, manualRuleCount: 0, sourceRuleCount: 0, warnings: [] },
activeProxyId: 'direct',
activeProxyId: '',
customUserAgentProfiles: [],
userAgentAssignments: [],
bridge: {
@@ -109,6 +109,11 @@ function normalizeManagedInstance(input: unknown): BridgeConfig['managedInstance
return value as NonNullable<BridgeConfig['managedInstance']>;
}
function normalizeBrowserMetadata(input: unknown, maxLength: number): string | undefined {
if (typeof input !== 'string') return undefined;
return input.trim().slice(0, maxLength) || undefined;
}
function normalizeState(value: Partial<ExtensionState>): ExtensionState {
const profileMap = new Map(defaultProfiles().map((profile) => [profile.id, profile]));
const storedProfiles = Array.isArray(value.proxyProfiles) ? value.proxyProfiles.slice(0, 500) : [];
@@ -183,14 +188,16 @@ function normalizeState(value: Partial<ExtensionState>): ExtensionState {
...(value.proxyRuntime && typeof value.proxyRuntime === 'object' ? value.proxyRuntime : {}),
warnings: Array.isArray(value.proxyRuntime?.warnings) ? value.proxyRuntime.warnings.slice(0, 100) : [],
},
activeProxyId: value.activeProxyId === 'auto' || proxyProfiles.some((profile) => profile.id === value.activeProxyId)
activeProxyId: value.activeProxyId === '' || value.activeProxyId === 'auto' || proxyProfiles.some((profile) => profile.id === value.activeProxyId)
? value.activeProxyId!
: 'direct',
: '',
customUserAgentProfiles: userAgentState.customUserAgentProfiles,
userAgentAssignments: userAgentState.userAgentAssignments,
bridge: {
...DEFAULT_STATE.bridge,
...value.bridge,
browserName: normalizeBrowserMetadata(value.bridge?.browserName, 120),
browserVersion: normalizeBrowserMetadata(value.bridge?.browserVersion, 80),
managedInstance: normalizeManagedInstance(value.bridge?.managedInstance),
},
floatingPanel: {
@@ -259,6 +266,7 @@ export async function setState(input: ExtensionState): Promise<ExtensionState> {
proxyProfiles: state.proxyProfiles, proxyRules: state.proxyRules,
proxyRuleSources: state.proxyRuleSources, proxyRouting: state.proxyRouting,
proxyRuntime: state.proxyRuntime, activeProxyId: state.activeProxyId,
startupProxy: state.startupProxy,
},
[USER_AGENT_SETTINGS_STORAGE_KEY]: {
customUserAgentProfiles: state.customUserAgentProfiles,
+21
View File
@@ -93,6 +93,10 @@ describe('Bridge v3 protocol', () => {
});
it('accepts automatic selected-frame capture and rejects the legacy expression contract', () => {
expect(parseCapabilityParams('browser.callable.create', {
source: 'recording', callHandleId: 'call-1', name: 'Dynamic decrypt',
dynamicInputPaths: ['$input', '$input.key', '$input.iv'],
})).toMatchObject({ dynamicInputPaths: ['$input', '$input.key', '$input.iv'] });
expect(parseCapabilityParams('browser.callable.create', {
source: 'deep-capture', strategy: 'selected-frame', callFrameId: 'frame-1', name: 'Envelope',
candidateId: 'candidate-envelope',
@@ -131,6 +135,12 @@ describe('Bridge v3 protocol', () => {
expect(parseCapabilityParams('browser.recording.trace.list', {
tabId: 12, frameId: 0, limit: 20,
})).toMatchObject({ limit: 20 });
expect(parseCapabilityParams('browser.crypto.inspect', {
tabId: 12, captureId: 'capture-1', nodeId: 'n1', settleMs: 2_000,
})).toMatchObject({ captureId: 'capture-1', nodeId: 'n1', settleMs: 2_000 });
expect(() => parseCapabilityParams('browser.crypto.inspect', {
captureId: 'capture-1', nodeId: 'n1', settleMs: 30_000,
})).toThrow();
expect(parseCapabilityParams('browser.recording.evidence.inspect', {
tabId: 12, traceId: 'trace-1', includeValues: false,
})).toMatchObject({ traceId: 'trace-1', includeValues: false });
@@ -163,6 +173,12 @@ describe('Bridge v3 protocol', () => {
candidateId: 'candidate-1',
callableId: 'callable-1',
});
expect(parseCapabilityParams('browser.transform.prepare', {
tabId: 12,
candidateId: 'candidate-1',
inputPaths: ['body'],
packet,
})).toMatchObject({ candidateId: 'candidate-1', packet });
expect(parseCapabilityParams('browser.transform.recovery.start', {
id: 'profile-1',
})).toMatchObject({ id: 'profile-1' });
@@ -186,6 +202,11 @@ describe('Bridge v3 protocol', () => {
id: 'profile-1',
validationId: 'validation-1',
})).toMatchObject({ validationId: 'validation-1' });
expect(parseCapabilityParams('browser.transform.validation.execute', {
validationId: 'validation-1',
direction: 'request',
packet,
})).toMatchObject({ validationId: 'validation-1', direction: 'request' });
expect(() => parseCapabilityParams('browser.profile.validate', {
tabId: 12,
profile: {},
+23
View File
@@ -4,6 +4,7 @@ import type { BridgePublicKey } from '@/types/models';
import {
browserTransformExecuteSchema,
browserTransformPacketSchema,
browserTransformProfileInputSchema,
} from './transform';
export const BRIDGE_PROTOCOL_VERSION = 3;
@@ -114,6 +115,12 @@ export const capabilityParams = {
action: v.picklist(['click', 'focus', 'scroll', 'setValue']),
value: v.optional(v.pipe(v.string(), v.maxLength(100_000))),
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
'browser.crypto.inspect': v.strictObject({
...targetFields,
captureId,
nodeId,
settleMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(5_000))),
}),
'browser.cookies': v.optional(v.strictObject(targetFields)),
'browser.takeover': v.optional(v.strictObject(targetFields)),
'browser.instance.close': v.optional(v.strictObject({})),
@@ -177,6 +184,7 @@ export const capabilityParams = {
v.strictObject({
...targetFields, source: v.literal('recording'), callHandleId: id,
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
dynamicInputPaths: v.optional(v.pipe(v.array(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160))), v.maxLength(16))),
}),
v.strictObject({
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
@@ -230,6 +238,15 @@ export const capabilityParams = {
observed: v.optional(browserTransformPacketSchema),
comparisonMode: v.optional(v.picklist(['structure', 'exact'])),
}),
'browser.transform.prepare': v.strictObject({
...targetFields,
candidateId: id,
trigger: v.optional(v.strictObject({ captureId: id, nodeId: id })),
inputPaths: v.optional(v.pipe(v.array(valuePath), v.maxLength(64))),
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
packet: browserTransformPacketSchema,
}),
'browser.transform.validation.get': v.strictObject({ validationId: id }),
'browser.deep_capture.start': v.strictObject({ ...targetFields, matcher: deepCaptureMatcher }),
'browser.deep_capture.status': v.optional(v.strictObject(targetFields)),
'browser.deep_capture.keepalive': v.optional(v.strictObject(targetFields)),
@@ -237,6 +254,7 @@ export const capabilityParams = {
'browser.deep_capture.detach': v.optional(v.strictObject(targetFields)),
'browser.transform.profile.list': v.optional(v.strictObject(targetFields)),
'browser.transform.profile.delete': v.strictObject({ id }),
'browser.transform.profile.save': browserTransformProfileInputSchema,
'browser.transform.recovery.get': v.strictObject({ id }),
'browser.transform.recovery.start': v.strictObject({ id }),
'browser.transform.recovery.capture': v.strictObject({
@@ -252,6 +270,11 @@ export const capabilityParams = {
'browser.transform.recovery.confirm': v.strictObject({ id, validationId: id }),
'browser.transform.recovery.reset': v.strictObject({ id }),
'browser.transform.execute': browserTransformExecuteSchema,
'browser.transform.validation.execute': v.strictObject({
validationId: id,
direction: v.picklist(['request', 'response']),
packet: browserTransformPacketSchema,
}),
'browser.invoke': v.strictObject({
...targetFields,
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
+27 -1
View File
@@ -35,6 +35,18 @@ describe('versioned Bridge capability catalog', () => {
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"mode"');
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"program"');
expect(capabilityBaseScope('browser.profile.validate')).toBe('browser.transform.execute');
expect(catalog.capabilities.find((capability) => capability.method === 'browser.crypto.inspect')).toMatchObject({
domain: 'recording',
access: 'execute',
scopes: [
'browser.dom.write',
'browser.recording.control',
'browser.recording.sensitive.read',
'browser.network.capture',
'browser.network.sensitive.read',
],
targetMode: 'document',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.thumbnail')).toMatchObject({
agentVisible: false,
});
@@ -52,7 +64,13 @@ describe('versioned Bridge capability catalog', () => {
expect(capabilityVisibleToAgent('browser.handoff.resolve')).toBe(false);
expect(capabilityVisibleToAgent('browser.thumbnail')).toBe(false);
expect(capabilityVisibleToAgent('browser.context')).toBe(true);
expect(catalog.capabilities.find((capability) => capability.method === 'browser.transform.profile.save')).toBeUndefined();
expect(catalog.capabilities.find((capability) => capability.method === 'browser.transform.profile.save')).toMatchObject({
access: 'write', scopes: ['browser.transform.manage'],
});
expect(capabilityVisibleToAgent('browser.transform.profile.save')).toBe(true);
expect(catalog.capabilities.find((capability) => capability.method === 'browser.transform.validation.get')).toMatchObject({
access: 'read', scopes: ['browser.transform.read'],
});
expect(catalog.capabilities.find((capability) => capability.method === 'proxy.switch')?.summary)
.toContain('不会生成、启用或执行 Transform Profile');
expect(catalog.capabilities.find((capability) => capability.method === 'browser.profile.validate')?.summary)
@@ -63,6 +81,14 @@ describe('versioned Bridge capability catalog', () => {
targetMode: 'profile',
});
expect(capabilityBaseScope('browser.transform.recovery.validate')).toBe('browser.transform.execute');
expect(catalog.capabilities.find((capability) => capability.method === 'browser.transform.validation.execute')).toMatchObject({
domain: 'transform',
access: 'execute',
scopes: ['browser.transform.execute'],
targetMode: 'profile',
});
expect(capabilityVisibleToAgent('browser.transform.validation.execute')).toBe(true);
expect(capabilityVisibleToAgent('browser.transform.prepare')).toBe(true);
expect(catalog.capabilities.find((capability) => capability.method === 'browser.isolation.proof')).toMatchObject({
domain: 'isolation',
access: 'sensitive-read',
+36 -6
View File
@@ -91,6 +91,18 @@ const CAPABILITY_METADATA = {
domain: 'page', access: 'write', summary: '点击、聚焦、滚动或填写稳定页面节点',
scopes: ['browser.dom.write'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.crypto.inspect': {
domain: 'recording', access: 'execute',
summary: '原子化触发一个可见页面节点,返回加解密、编码、网络证据及动作后的新页面节点;以非阻塞本地默认值处理 alert/confirm/prompt,并为明文转换准备候选',
scopes: [
'browser.dom.write',
'browser.recording.control',
'browser.recording.sensitive.read',
'browser.network.capture',
'browser.network.sensitive.read',
],
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.cookies': {
domain: 'page', access: 'sensitive-read', summary: '读取目标页面 Cookie,包括已授权的 HttpOnly 值',
scopes: ['browser.cookies.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
@@ -162,7 +174,7 @@ const CAPABILITY_METADATA = {
scopes: ['browser.network.sensitive.read'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.recording.start': {
domain: 'recording', access: 'control', summary: '开始业务 Trace 录制;生成新明文网关时先录制一次真实业务操作,再检查候选证据',
domain: 'recording', access: 'control', summary: '开始业务 Trace 录制;由插件本地诊断与高层能力使用',
scopes: ['browser.recording.control'],
conditionalScopes: [{ scope: 'browser.recording.sensitive.read', when: 'captureValues=true' }],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
@@ -194,7 +206,7 @@ const CAPABILITY_METADATA = {
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.callable.create': {
domain: 'callable', access: 'execute', summary: '从录制句柄或深度捕获 Frame 创建页面函数;生成明文网关 Profile 前需要得到可回放函数',
domain: 'callable', access: 'execute', summary: '从录制句柄或深度捕获 Frame 创建页面函数',
scopes: ['browser.callable.execute'],
conditionalScopes: [{ scope: 'browser.debugger.control', when: 'source=deep-capture' }],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
@@ -240,13 +252,17 @@ const CAPABILITY_METADATA = {
scopes: ['browser.debugger.control'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.transform.profile.list': {
domain: 'transform', access: 'read', summary: '明文网关入口:先列出目标页面已有 Profile;已有配置可直接用 transform.execute,无配置再走录制、提案和验证',
domain: 'transform', access: 'read', summary: '列出目标页面已有的明文转换;无匹配配置时使用 browser.crypto.inspect 和 browser.transform.prepare',
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.transform.profile.delete': {
domain: 'transform', access: 'write', summary: '删除 Transform Profile',
scopes: ['browser.transform.manage'], targetMode: 'profile', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.transform.profile.save': {
domain: 'transform', access: 'write', summary: '保存或更新经验证的明文网关;仅在用户需要持久保存时使用,普通测试优先短时草稿',
scopes: ['browser.transform.manage'], targetMode: 'none', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.transform.recovery.get': {
domain: 'transform', access: 'read', summary: '读取 Profile 的非敏感文档恢复计划和确定性状态',
scopes: ['browser.transform.read'], targetMode: 'profile', defaultTimeoutMs: READ_TIMEOUT_MS,
@@ -277,24 +293,38 @@ const CAPABILITY_METADATA = {
domain: 'transform', access: 'execute', summary: '使用已保存的 Profile 对 HTTP 报文执行请求加密或响应解密;它不是网络代理切换',
scopes: ['browser.transform.execute'], targetMode: 'profile', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.transform.validation.execute': {
domain: 'transform', access: 'execute', summary: '使用 Agent 已验证的短时草稿执行请求加密或响应解密,不会永久保存 Profile',
scopes: ['browser.transform.execute'], targetMode: 'profile', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.packet.compare': {
domain: 'transform', access: 'read', summary: '按结构或精确模式比较两份 HTTP 报文',
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.profile.propose': {
domain: 'transform', access: 'read', summary: '从录制候选和页面函数编译未保存的 Profile 提案;下一步必须调用 profile.validate',
domain: 'transform', access: 'read', summary: '从录制候选和页面函数编译未保存的 Profile 提案',
scopes: ['browser.transform.read', 'browser.recording.read'],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.profile.validation.latest': {
domain: 'transform', access: 'read', summary: '读取当前文档最近的短时验证草稿及本地确认状态;草稿过期后需重新验证',
domain: 'transform', access: 'read', summary: '读取当前文档最近的短时验证草稿及本地确认状态',
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.profile.validate': {
domain: 'transform', access: 'execute', summary: '确定性执行 Profile 提案并与证据比较;成功后只生成短时草稿,必须由用户在插件本地确认保存',
domain: 'transform', access: 'execute', summary: '确定性执行 Profile 提案并与证据比较;保存仍需用户在插件本地确认保存',
scopes: ['browser.transform.execute', 'browser.recording.read'],
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.transform.prepare': {
domain: 'transform', access: 'execute',
summary: '从 browser.crypto.inspect 候选准备双向短时网关;自动重触发原操作并捕获缺失业务方向,无需插件 UI;节点变化时可传入新的 trigger',
scopes: ['browser.transform.execute', 'browser.recording.read', 'browser.callable.execute'],
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.transform.validation.get': {
domain: 'transform', access: 'read', summary: '读取短时网关的有效期和已启用方向;不返回页面闭包或明文样本',
scopes: ['browser.transform.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.invoke': {
domain: 'page', access: 'dangerous', summary: '在页面 MAIN world 调用具名函数路径',
scopes: ['browser.page.invoke'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
+11 -1
View File
@@ -119,6 +119,16 @@ describe('extension request schemas', () => {
});
it('validates manager-owned browser instance binding', () => {
const binding = {
manager: 'ytray', instanceId: 'instance-a', badge: 'A',
browserName: 'Chrome for Testing', browserVersion: '152.0.7977.82',
};
for (const startupProxy of ['direct', 'http://127.0.0.1:8083', 'https://proxy.example:443']) {
expect(parseExtensionRequest({ action: 'bridge.managed-instance.bind', payload: { ...binding, startupProxy } }).action).toBe('bridge.managed-instance.bind');
}
for (const startupProxy of ['http://user:[email protected]:8083', 'http://proxy.example/path', 'javascript:alert(1)']) {
expect(() => parseExtensionRequest({ action: 'bridge.managed-instance.bind', payload: { ...binding, startupProxy } })).toThrow();
}
expect(parseExtensionRequest({
action: 'bridge.managed-instance.bind',
payload: { manager: 'ytray', instanceId: '13367db6-232a-40d1-ad84-81ee5d97634f', badge: 'B' },
@@ -132,7 +142,7 @@ describe('extension request schemas', () => {
it('validates recording bounds and recorded page callables', () => {
expect(parseExtensionRequest({
action: 'recording.start',
payload: { tabId: 12, frameId: 0, captureValues: false, maxEntries: 500, maxValueBytes: 8_192 },
payload: { tabId: 12, frameId: 0, scope: 'tab', captureValues: false, maxEntries: 500, maxValueBytes: 8_192 },
}).action).toBe('recording.start');
expect(() => parseExtensionRequest({
action: 'recording.start', payload: { tabId: 12, maxEntries: 501 },
+19 -5
View File
@@ -179,6 +179,8 @@ const bridgeConfig = v.strictObject({
endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)),
autoConnect: v.boolean(),
installationId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)),
browserName: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
browserVersion: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80))),
managedInstance: v.optional(managedInstance),
pairedEngine: v.optional(v.strictObject({
engineIdentityId: id,
@@ -301,6 +303,8 @@ const payloadSchemas = {
'proxy.save': proxyProfile,
'proxy.delete': v.strictObject({ id }),
'proxy.switch': v.strictObject({ id }),
'proxy.status': noPayload,
'proxy.release': noPayload,
'proxy.rule.save': proxyRule,
'proxy.rule.delete': v.strictObject({ id }),
'proxy.auto.apply': noPayload,
@@ -397,18 +401,20 @@ const payloadSchemas = {
'network.capture.analysis': v.strictObject({ ...targetFields, id }),
'recording.start': v.strictObject({
...targetFields,
scope: v.optional(v.picklist(['frame', 'tab'])),
captureValues: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(20), v.maxValue(500))),
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
}),
'recording.status': v.strictObject(targetFields),
'recording.get': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))) }),
'recording.clear': v.strictObject(targetFields),
'recording.stop': v.strictObject(targetFields),
'recording.status': v.strictObject({ ...targetFields, scope: v.optional(v.picklist(['frame', 'tab'])) }),
'recording.get': v.strictObject({ ...targetFields, scope: v.optional(v.picklist(['frame', 'tab'])), limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))) }),
'recording.clear': v.strictObject({ ...targetFields, scope: v.optional(v.picklist(['frame', 'tab'])) }),
'recording.stop': v.strictObject({ ...targetFields, scope: v.optional(v.picklist(['frame', 'tab'])) }),
'callable.create': v.union([
v.strictObject({
...targetFields, source: v.literal('recording'), callHandleId: id,
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
dynamicInputPaths: v.optional(v.pipe(v.array(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160))), v.maxLength(16))),
}),
v.strictObject({
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
@@ -471,7 +477,15 @@ const payloadSchemas = {
'metrics.get': noPayload,
'metrics.reset': noPayload,
'bridge.config.save': bridgeConfig,
'bridge.managed-instance.bind': managedInstance,
'bridge.managed-instance.bind': v.strictObject({
...managedInstance.entries,
browserName: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
browserVersion: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80))),
startupProxy: v.optional(v.union([v.literal('direct'), v.pipe(httpUrl, v.check((value) => {
const parsed = new URL(value);
return !parsed.username && !parsed.password && !parsed.search && !parsed.hash && parsed.pathname === '/';
}, '启动代理只能包含协议、主机和端口'))])),
}),
'bridge.pair': noPayload,
'bridge.pair.cancel': noPayload,
'bridge.pair.status': noPayload,
+38
View File
@@ -0,0 +1,38 @@
export async function readRequestBody(request: Request, maximumBytes = 8 * 1024 * 1024): Promise<{
text: string;
value: string | FormData;
contentType: string;
}> {
const contentType = request.headers.get('content-type') || '';
const reader = request.clone().body?.getReader();
const chunks: Uint8Array[] = [];
let length = 0;
if (reader) {
try {
while (true) {
const next = await reader.read();
if (next.done) break;
length += next.value.byteLength;
if (length > maximumBytes) {
void reader.cancel().catch(() => undefined);
throw new Error(`请求 Body 超过 ${maximumBytes} B`);
}
chunks.push(next.value);
}
} finally { reader.releaseLock(); }
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
if (contentType.toLowerCase().includes('multipart/form-data')) {
const value = await new Response(bytes, { headers: { 'content-type': contentType } }).formData();
const fields = new URLSearchParams();
for (const [key, item] of value) {
if (typeof item !== 'string') throw new Error(`表单字段 ${key} 包含文件,暂不允许自动回放`);
fields.append(key, item);
}
return { text: fields.toString(), value, contentType: 'application/x-www-form-urlencoded' };
}
const text = new TextDecoder().decode(bytes);
return { text, value: text, contentType };
}
+1
View File
@@ -80,6 +80,7 @@ input, select, textarea {
color: var(--foreground);
font-size: var(--text-md);
}
select option, select optgroup { background: var(--surface); color: var(--foreground); }
input, select { height: 36px; padding: 0 11px; }
textarea { min-height: 78px; padding: 9px 11px; line-height: 1.5; resize: vertical; }
input::placeholder, textarea::placeholder { color: var(--muted); }
+11 -7
View File
@@ -99,6 +99,8 @@ export interface ExtensionRequestMap {
'proxy.save': { input: ProxyProfile; output: ExtensionState };
'proxy.delete': { input: { id: string }; output: ExtensionState };
'proxy.switch': { input: { id: string }; output: ExtensionState };
'proxy.status': { input: undefined; output: import('./models').ProxyStatus };
'proxy.release': { input: undefined; output: ExtensionState };
'proxy.rule.save': { input: ProxyRule; output: ExtensionState };
'proxy.rule.delete': { input: { id: string }; output: ExtensionState };
'proxy.auto.apply': { input: undefined; output: ExtensionState };
@@ -152,13 +154,13 @@ export interface ExtensionRequestMap {
'network.capture.send': { input: { id: string; tabId?: number; frameId?: number; documentId?: string }; output: YakitFuzzerOpenResult };
'network.capture.poc': { input: { id: string; tabId?: number; frameId?: number; documentId?: string }; output: YakPocGenerateResult };
'network.capture.analysis': { input: { id: string; tabId?: number; frameId?: number; documentId?: string }; output: BrowserRequestAnalysisBundle };
'recording.start': { input: { tabId?: number; frameId?: number; documentId?: string; captureValues?: boolean; maxEntries?: number; maxValueBytes?: number }; output: BrowserRecordingSnapshot };
'recording.status': { input: { tabId?: number; frameId?: number; documentId?: string }; output: BrowserRecordingStatus };
'recording.get': { input: { tabId?: number; frameId?: number; documentId?: string; limit?: number }; output: BrowserRecordingSnapshot };
'recording.clear': { input: { tabId?: number; frameId?: number; documentId?: string }; output: BrowserRecordingSnapshot };
'recording.stop': { input: { tabId?: number; frameId?: number; documentId?: string }; output: BrowserRecordingSnapshot };
'recording.start': { input: { tabId?: number; frameId?: number; documentId?: string; scope?: 'frame' | 'tab'; captureValues?: boolean; maxEntries?: number; maxValueBytes?: number }; output: BrowserRecordingSnapshot };
'recording.status': { input: { tabId?: number; frameId?: number; documentId?: string; scope?: 'frame' | 'tab' }; output: BrowserRecordingStatus };
'recording.get': { input: { tabId?: number; frameId?: number; documentId?: string; scope?: 'frame' | 'tab'; limit?: number }; output: BrowserRecordingSnapshot };
'recording.clear': { input: { tabId?: number; frameId?: number; documentId?: string; scope?: 'frame' | 'tab' }; output: BrowserRecordingSnapshot };
'recording.stop': { input: { tabId?: number; frameId?: number; documentId?: string; scope?: 'frame' | 'tab' }; output: BrowserRecordingSnapshot };
'callable.create': { input: ({ tabId?: number; frameId?: number; documentId?: string } & (
| { source: 'recording'; callHandleId: string; name: string }
| { source: 'recording'; callHandleId: string; name: string; dynamicInputPaths?: string[] }
| { source: 'deep-capture'; strategy: 'selected-frame'; callFrameId: string; name?: string; candidateId?: string }
| { source: 'deep-capture'; strategy: 'request-transaction'; callFrameId: string; name?: string; candidateId: string }
| { source: 'deep-capture'; strategy: 'expression'; callFrameId: string; name: string; functionExpression: string }
@@ -250,7 +252,9 @@ export interface ExtensionRequestMap {
'metrics.reset': { input: undefined; output: RuntimeMetrics };
'bridge.config.save': { input: BridgeConfig; output: ExtensionState };
'bridge.managed-instance.bind': {
input: NonNullable<BridgeConfig['managedInstance']>;
input: NonNullable<BridgeConfig['managedInstance']> & Pick<BridgeConfig, 'browserName' | 'browserVersion'> & {
startupProxy?: string;
};
output: BridgeStatus;
};
'bridge.pair': { input: undefined; output: BridgePairingStatus };
+37 -3
View File
@@ -113,6 +113,13 @@ export interface ProxyRulePage {
rules: NormalizedProxyRule[];
}
export interface ProxyStatus {
followingStartup?: boolean;
control: string;
label: string;
activeProfileId?: string;
}
export interface ProxyRuntimeState {
dirty: boolean;
compiledBytes: number;
@@ -196,6 +203,8 @@ export interface BridgeConfig {
endpoint: string;
autoConnect: boolean;
installationId: string;
browserName?: string;
browserVersion?: string;
managedInstance?: {
manager: 'ytray' | 'yakit';
instanceId: string;
@@ -550,6 +559,7 @@ export interface BrowserRecordingEvent {
parentEventId?: string;
kind: BrowserRecordingEventKind;
source?: 'page' | 'browser';
frameId?: number;
documentId?: string;
operation: string;
label?: string;
@@ -582,6 +592,7 @@ export interface BrowserRecordingEvent {
export interface BrowserRecordingStatus {
active: boolean;
target: BrowserTarget;
scope?: 'frame' | 'tab';
isolationContextId?: string;
cookieStoreId?: string;
documentAvailable: boolean;
@@ -835,6 +846,7 @@ export interface BrowserProfileInferenceSource {
operation: string;
crypto?: BrowserRecordingCrypto;
callHandleId?: string;
dynamicInputPaths?: string[];
arguments: BrowserRecordingCallArgument[];
destination?: string;
serialization?: BrowserProfileInferenceSerialization;
@@ -859,6 +871,7 @@ export interface BrowserProfileInferenceCandidate {
id: string;
recordingId: string;
traceId: string;
transactionId?: string;
target: BrowserTarget;
direction: 'request' | 'response';
request: {
@@ -918,6 +931,7 @@ export interface BrowserDeepCaptureFrame {
sourceMapUrl?: string;
lineNumber: number;
columnNumber: number;
functionLocation?: { lineNumber: number; columnNumber: number };
scopes: BrowserDeepCaptureScope[];
thisPreview: string;
sourceKind: 'page' | 'extension-hook' | 'library';
@@ -927,7 +941,7 @@ export interface BrowserDeepCaptureFrame {
parameterCount?: number;
parameterNames?: string[];
riskFlags: Array<'network' | 'dom' | 'navigation' | 'storage'>;
resolution?: 'frame-name' | 'receiver-method' | 'scope-binding' | 'manual-expression';
resolution?: 'frame-name' | 'receiver-method' | 'scope-binding' | 'current-function' | 'event-listener' | 'manual-expression';
referenceExpression?: string;
candidateCount?: number;
};
@@ -1271,7 +1285,15 @@ export interface BrowserTransformExecuteInput {
packet: BrowserTransformPacket;
}
export interface BrowserTransformValidationExecuteInput {
validationId: string;
direction: BrowserTransformDirectionName;
packet: BrowserTransformPacket;
}
export interface BrowserTransformExecution {
explanation?: BrowserTransformExplanation;
proofLevel?: 'structure' | 'exact' | 'execution-only';
profileId: string;
direction: BrowserTransformDirectionName;
url: string;
@@ -1318,7 +1340,9 @@ export interface BrowserTransformProfileValidationResult {
generated: BrowserTransformPacket;
execution: BrowserTransformExecution;
comparison?: BrowserPacketComparison;
validationDraft?: Pick<BrowserTransformValidationDraft, 'contractVersion' | 'id' | 'createdAt' | 'expiresAt'>;
validationDraft?: Pick<BrowserTransformValidationDraft, 'contractVersion' | 'id' | 'createdAt' | 'expiresAt'> & {
directions: { request: boolean; response: boolean };
};
next: string;
}
@@ -1480,6 +1504,7 @@ export interface DiagnosticsBundle {
}
export interface ExtensionState {
startupProxy?: string;
version: 7;
proxyProfiles: ProxyProfile[];
proxyRules: ProxyRule[];
@@ -1732,6 +1757,13 @@ export interface PageContextDiff {
export type PageNodeAction = 'click' | 'focus' | 'scroll' | 'setValue';
export interface PageDialog {
type: 'alert' | 'confirm' | 'prompt';
message: string;
decision: 'auto_dismissed' | 'auto_accepted' | 'auto_submitted';
timestamp: number;
}
export interface PageNodeDetails extends PageNodeSummary {
reference: PageNodeReference;
connected: boolean;
@@ -1741,8 +1773,10 @@ export interface PageNodeDetails extends PageNodeSummary {
export interface PageNodeActionResult {
action: PageNodeAction;
completedAt: number;
status: 'dispatched';
dispatchedAt: number;
node: PageNodeDetails;
dialogs?: PageDialog[];
}
export interface PageEvalRequest {