feat: advance browser agent integration workflows

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent af5a4db694
commit 0c8e1c7b69
215 changed files with 35137 additions and 5442 deletions
@@ -0,0 +1,149 @@
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')
})
})
@@ -1,15 +1,22 @@
import { describe, expect, it } from 'vitest'
import type { BrowserPageCallableTransaction } from '@/types/models'
import { requestMatchesTransaction, validateRequestTransactionOutput } from './request-transaction'
import {
requestMatchesTransaction,
resolveRequestTransactionFetchInput,
validateRequestTransactionOutput,
} from './request-transaction'
const transaction: BrowserPageCallableTransaction = {
version: 2,
prerequisites: [],
request: {
boundary: 'fetch',
method: 'POST',
url: 'encrypt/aesrsa.php?mode=login',
expectedDestinations: ['body.encryptedData', 'body.encryptedKey', 'body.encryptedIv'],
bodyFormat: 'json',
},
inputMode: 'auto',
boundaries: ['fetch', 'xhr', 'beacon', 'form'],
}
describe('request transaction contract', () => {
@@ -20,6 +27,10 @@ describe('request transaction contract', () => {
'http://127.0.0.1:82/login/encrypt/aesrsa.php?mode=login',
'http://127.0.0.1:82/login/index.html',
)).toBe(true)
expect(resolveRequestTransactionFetchInput(
'encrypt/aesrsa.php',
'http://127.0.0.1:82/login/index.html',
)).toBe('http://127.0.0.1:82/login/encrypt/aesrsa.php')
})
it('rejects another method, origin, path, or query', () => {
+166 -22
View File
@@ -64,6 +64,14 @@ function comparableUrl(value: string, baseUrl: string): string {
}
}
export function resolveRequestTransactionFetchInput(
input: RequestInfo | URL,
baseUrl = runtimeBaseUrl(),
): RequestInfo | URL {
if (typeof Request !== 'undefined' && input instanceof Request) return input
try { return new URL(String(input), baseUrl).toString() } catch { return input }
}
export function requestMatchesTransaction(
transaction: BrowserPageCallableTransaction,
method: string,
@@ -74,6 +82,16 @@ export function requestMatchesTransaction(
&& comparableUrl(transaction.request.url, baseUrl) === comparableUrl(url, baseUrl)
}
function requestMatchesStep(
step: { boundary: CapturedRequest['boundary']; method: string; url: string },
request: CapturedRequest,
baseUrl = runtimeBaseUrl(),
): boolean {
return step.boundary === request.boundary
&& step.method.toUpperCase() === request.method.toUpperCase()
&& comparableUrl(step.url, baseUrl) === comparableUrl(request.url, baseUrl)
}
function byteLength(value: string): number {
return new TextEncoder().encode(value).byteLength
}
@@ -111,15 +129,97 @@ function parseForm(value: string): Record<string, string | string[]> {
return output
}
function capturedBody(request: CapturedRequest): unknown {
function capturedBodyFormat(request: CapturedRequest): BrowserPageCallableTransaction['request']['bodyFormat'] {
const contentType = request.headers['content-type']?.toLowerCase() || ''
if (contentType.includes('application/json') || /^[\s\n\r]*[\[{]/.test(request.bodyText)) {
if (contentType.includes('application/x-www-form-urlencoded')) return 'form'
if (contentType.includes('application/json') || /^[\s\n\r]*[\[{]/.test(request.bodyText)) return 'json'
return 'raw'
}
function capturedBody(request: CapturedRequest): unknown {
const format = capturedBodyFormat(request)
if (format === 'json') {
try { return JSON.parse(request.bodyText) as unknown } catch { throw error('页面生成的请求 Body 不是有效 JSON') }
}
if (contentType.includes('application/x-www-form-urlencoded')) return parseForm(request.bodyText)
if (format === 'form') return parseForm(request.bodyText)
return request.bodyText
}
function bodyValue(text: string, format: BrowserPageCallableTransaction['request']['bodyFormat']): unknown {
if (format === 'json') {
try { return JSON.parse(text) as unknown } catch { throw error('在线前置请求返回的 Body 不是有效 JSON') }
}
if (format === 'form') return parseForm(text)
return text
}
function validatePrerequisiteRequest(
step: BrowserPageCallableTransaction['prerequisites'][number],
request: CapturedRequest,
): void {
const length = byteLength(request.bodyText)
if (length > step.maxRequestBodyBytes) {
throw error(`在线前置请求 Body 超过计划上限 ${step.maxRequestBodyBytes} B`)
}
if (step.requestBodyFormat === 'none') {
if (length !== 0) throw error('在线前置请求意外携带了 Body')
return
}
const actual = capturedBodyFormat(request)
if (actual !== step.requestBodyFormat) {
throw error(`在线前置请求生成了 ${actual} Body,但录制证据要求 ${step.requestBodyFormat} Body`)
}
}
async function readBoundedResponseText(response: Response, maximumBytes: number): Promise<string> {
const declaredLength = Number(response.headers.get('content-length'))
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
throw error(`在线前置响应超过计划上限 ${maximumBytes} B`)
}
const clone = response.clone()
if (!clone.body) return ''
const reader = clone.body.getReader()
const chunks: Uint8Array[] = []
let length = 0
try {
while (true) {
const result = await reader.read()
if (result.done) break
if (!result.value) continue
length += result.value.byteLength
if (length > maximumBytes) {
await reader.cancel().catch(() => undefined)
throw error(`在线前置响应超过计划上限 ${maximumBytes} B`)
}
chunks.push(result.value)
}
} finally {
reader.releaseLock()
}
const bytes = new Uint8Array(length)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
return new TextDecoder().decode(bytes)
}
async function validatePrerequisiteResponse(
step: BrowserPageCallableTransaction['prerequisites'][number],
response: Response,
): Promise<void> {
if (response.status !== step.response.statusCode) {
throw error(`在线前置响应状态为 ${response.status},录制证据要求 ${step.response.statusCode}`)
}
if (comparableUrl(response.url, runtimeBaseUrl()) !== comparableUrl(step.response.url, runtimeBaseUrl())) {
throw error(`在线前置响应到达未计划 URL ${response.url || '(empty)'}`)
}
const text = await readBoundedResponseText(response, step.response.maxBodyBytes)
const value = bodyValue(text, step.response.bodyFormat)
validateRequestTransactionOutput(value, step.response.requiredPaths)
}
function readOwnPath(input: unknown, path: string): unknown {
let current = input
for (const segment of path.split('.').filter(Boolean)) {
@@ -300,43 +400,82 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
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) {
captureFailure = error('页面流程产生了多个网络请求,无法唯一确定转换边界')
resolveCapture()
throw captureFailure
throw fail('页面流程产生了目标请求之外的额外网络请求')
}
if (!requestMatchesTransaction(input.transaction, request.method, request.url)) {
captureFailure = error(`页面尝试访问未授权请求 ${request.method} ${request.url}`)
resolveCapture()
throw captureFailure
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) {
captureFailure = error('页面生成的请求 Body 超过 8 MiB')
resolveCapture()
throw captureFailure
throw fail('页面生成的请求 Body 超过 8 MiB')
}
captured = request
resolveCapture()
}
const previousFetch = window.fetch
setMethod(window, 'fetch', (async function transactionFetch(requestInput: RequestInfo | URL, init?: RequestInit) {
const request = new Request(requestInput, init)
await capture({
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(),
})
return new Response(JSON.stringify({ success: false, error: 'request captured by Yakit Browser Agent' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
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> }>()
@@ -411,7 +550,7 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
await Promise.race([
captureSignal,
delay(timeoutMs).then(() => {
if (!captured && !captureFailure) captureFailure = error('等待页面生成目标请求超时')
if (!captured && !captureFailure) fail('等待页面完成在线依赖并生成目标请求超时')
}),
])
if (captureFailure) throw captureFailure
@@ -422,10 +561,15 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
await delay(0)
if (captureFailure) throw captureFailure
if (invocationFailure instanceof Error) throw error(invocationFailure.message)
const actualBodyFormat = capturedBodyFormat(captured)
if (actualBodyFormat !== input.transaction.request.bodyFormat) {
throw error(`页面生成了 ${actualBodyFormat} Body,但录制证据要求 ${input.transaction.request.bodyFormat} Body`)
}
const value = capturedBody(captured)
validateRequestTransactionOutput(value, input.transaction.request.expectedDestinations)
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. */ }
}
+19 -1
View File
@@ -17,12 +17,15 @@ const callable: Omit<BrowserPageCallable, 'target'> = {
paths: ['body.encryptedData', 'body.encryptedKey'],
},
transaction: {
version: 2,
prerequisites: [],
request: {
boundary: 'fetch',
method: 'POST', url: 'https://example.test/login',
expectedDestinations: ['body.encryptedData', 'body.encryptedKey'],
bodyFormat: 'json',
},
inputMode: 'auto',
boundaries: ['fetch', 'xhr'],
},
provenance: { eventId: 'request-1' },
createdAt: 1,
@@ -48,4 +51,19 @@ describe('page callable metadata contract', () => {
const { execution: _execution, ...legacy } = callable;
expect(normalizeCallable(legacy, target)).toBeUndefined();
});
it('rejects the legacy client-authored single-request transaction contract', () => {
expect(normalizeCallable({
...callable,
transaction: {
request: {
method: 'POST', url: 'https://example.test/login',
expectedDestinations: ['body.encryptedData', 'body.encryptedKey'],
bodyFormat: 'json',
},
inputMode: 'auto',
boundaries: ['fetch'],
},
}, target)).toBeUndefined();
});
});
+104 -10
View File
@@ -11,6 +11,7 @@ import type {
import { ExtensionError } from '@/shared/errors';
import { resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets';
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 {
MAX_CALLABLE_TIMEOUT_MS,
@@ -26,23 +27,54 @@ function normalizeTransaction(value: unknown): BrowserPageCallable['transaction'
if (!value || typeof value !== 'object') return undefined;
const input = value as Partial<NonNullable<BrowserPageCallable['transaction']>>;
const request = input.request as Partial<NonNullable<BrowserPageCallable['transaction']>['request']> | undefined;
if (!request || typeof request.method !== 'string' || typeof request.url !== 'string'
if (input.version !== 2 || !Array.isArray(input.prerequisites) || input.prerequisites.length > 4
|| !request || !['fetch', 'xhr', 'beacon', 'form'].includes(String(request.boundary))
|| typeof request.method !== 'string' || typeof request.url !== 'string'
|| !Array.isArray(request.expectedDestinations) || request.expectedDestinations.length === 0
|| request.expectedDestinations.some((item) => typeof item !== 'string' || !item.trim())) return undefined;
const boundaries = Array.isArray(input.boundaries)
? input.boundaries.filter((item): item is NonNullable<BrowserPageCallable['transaction']>['boundaries'][number] => (
['fetch', 'xhr', 'beacon', 'form'].includes(String(item))
)).slice(0, 4)
: ['fetch', 'xhr', 'beacon', 'form'] as NonNullable<BrowserPageCallable['transaction']>['boundaries'];
if (!boundaries.length) return undefined;
|| request.expectedDestinations.some((item) => typeof item !== 'string' || !item.trim())
|| !['json', 'form', 'raw'].includes(String(request.bodyFormat))) return undefined;
const prerequisites = input.prerequisites.flatMap((value) => {
if (!value || typeof value !== 'object') return [];
const item = value as Partial<NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]>;
const response = item.response as Partial<NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]['response']> | undefined;
if (item.boundary !== 'fetch' || typeof item.method !== 'string' || typeof item.url !== 'string'
|| !['none', 'json', 'form', 'raw'].includes(String(item.requestBodyFormat))
|| !Number.isSafeInteger(item.maxRequestBodyBytes) || Number(item.maxRequestBodyBytes) < 0
|| Number(item.maxRequestBodyBytes) > 8 * 1_024 * 1_024
|| !response || !Number.isSafeInteger(response.statusCode) || Number(response.statusCode) < 100
|| Number(response.statusCode) > 599 || typeof response.url !== 'string'
|| !['json', 'form', 'raw'].includes(String(response.bodyFormat))
|| !Number.isSafeInteger(response.maxBodyBytes) || Number(response.maxBodyBytes) < 1
|| Number(response.maxBodyBytes) > 8 * 1_024 * 1_024
|| !Array.isArray(response.requiredPaths) || response.requiredPaths.length === 0
|| response.requiredPaths.some((path) => typeof path !== 'string' || !path.trim())) return [];
return [{
boundary: 'fetch' as const,
method: item.method.toUpperCase().slice(0, 16),
url: item.url.slice(0, 4_096),
requestBodyFormat: item.requestBodyFormat as NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]['requestBodyFormat'],
maxRequestBodyBytes: Number(item.maxRequestBodyBytes),
response: {
statusCode: Number(response.statusCode),
url: response.url.slice(0, 4_096),
bodyFormat: response.bodyFormat as NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]['response']['bodyFormat'],
maxBodyBytes: Number(response.maxBodyBytes),
requiredPaths: [...new Set(response.requiredPaths.map((path) => path.trim().slice(0, 512)))].slice(0, 64),
},
}];
});
if (prerequisites.length !== input.prerequisites.length) return undefined;
return {
version: 2,
prerequisites,
request: {
boundary: request.boundary as NonNullable<BrowserPageCallable['transaction']>['request']['boundary'],
method: request.method.toUpperCase().slice(0, 16),
url: request.url.slice(0, 4_096),
expectedDestinations: request.expectedDestinations.slice(0, 64).map((item) => item.slice(0, 512)),
expectedDestinations: [...new Set(request.expectedDestinations.map((item) => item.trim().slice(0, 512)))].slice(0, 64),
bodyFormat: request.bodyFormat as NonNullable<BrowserPageCallable['transaction']>['request']['bodyFormat'],
},
inputMode: 'auto',
boundaries,
};
}
@@ -56,6 +88,51 @@ function normalizeExecution(value: unknown): BrowserPageCallable['execution'] |
return callableExecutionPolicy(input.resultMode as BrowserPageCallable['execution']['resultMode'], Number(input.timeoutMs));
}
function normalizeCallableAnalysis(
value: unknown,
): BrowserPageCallable['provenance']['analysis'] | undefined {
if (!value || typeof value !== 'object') return undefined;
const input = value as Partial<NonNullable<BrowserPageCallable['provenance']['analysis']>>;
if (input.version !== 1 || typeof input.traceId !== 'string'
|| !input.confidence || typeof input.confidence !== 'object'
|| !Number.isFinite(input.confidence.score)
|| !['high', 'medium', 'low'].includes(String(input.confidence.level))
|| !Array.isArray(input.flow) || !Array.isArray(input.operations) || !Array.isArray(input.evidence)) return undefined;
const evidenceKinds = new Set([
'request-boundary', 'response-boundary', 'exact-value', 'message-boundary',
'state-sequence', 'transform-lineage', 'callable', 'trace-order', 'heuristic',
]);
return {
version: 1,
traceId: input.traceId.slice(0, 160),
confidence: {
score: Math.max(0, Math.min(100, Number(input.confidence.score))),
level: input.confidence.level as 'high' | 'medium' | 'low',
},
flow: input.flow.slice(0, 32).flatMap((item) => typeof item === 'string' ? [item.slice(0, 240)] : []),
operations: input.operations.slice(0, 16).flatMap((operation) => (
operation && typeof operation.operation === 'string'
? [{
operation: operation.operation.slice(0, 240),
destination: typeof operation.destination === 'string' ? operation.destination.slice(0, 512) : undefined,
crypto: normalizeBrowserRecordingCrypto(operation.crypto),
}]
: []
)),
evidence: input.evidence.slice(0, 24).flatMap((item) => (
item && evidenceKinds.has(String(item.kind))
&& ['proven', 'supported'].includes(String(item.strength))
&& typeof item.label === 'string'
? [{
kind: item.kind,
strength: item.strength,
label: item.label.slice(0, 500),
}]
: []
)),
};
}
function normalizeCallable(value: unknown, target: BrowserTarget): BrowserPageCallable | undefined {
if (!value || typeof value !== 'object') return undefined;
const input = value as Partial<RawCallable>;
@@ -118,6 +195,22 @@ function normalizeCallable(value: unknown, target: BrowserTarget): BrowserPageCa
sourceUrl: typeof input.provenance.sourceUrl === 'string' ? input.provenance.sourceUrl.slice(0, 4_096) : undefined,
lineNumber: Number.isSafeInteger(input.provenance.lineNumber) ? Math.max(1, Number(input.provenance.lineNumber)) : undefined,
functionName: typeof input.provenance.functionName === 'string' ? input.provenance.functionName.slice(0, 240) : undefined,
businessFrameHints: Array.isArray(input.provenance.businessFrameHints)
? input.provenance.businessFrameHints.slice(0, 16).flatMap((hint) => (
hint
&& typeof hint.functionName === 'string'
&& Number.isFinite(hint.support)
&& Number.isFinite(hint.averageDepth)
? [{
functionName: hint.functionName.slice(0, 240),
url: typeof hint.url === 'string' ? hint.url.slice(0, 4_096) : undefined,
support: Math.max(0, Number(hint.support)),
averageDepth: Math.max(0, Number(hint.averageDepth)),
}]
: []
))
: undefined,
analysis: normalizeCallableAnalysis(input.provenance.analysis),
},
createdAt: Number.isFinite(input.createdAt) ? Math.max(0, Number(input.createdAt)) : Date.now(),
};
@@ -156,6 +249,7 @@ async function callPageController(
command: PageControllerCommand,
input: Record<string, unknown> = {},
): Promise<unknown> {
if (import.meta.env.FIREFOX) return executeFirefoxPageRecorderCommand(target, command, input);
const [result] = await browser.scripting.executeScript({
target: scriptingTarget(target),
world: 'MAIN',