diff --git a/src/entrypoints/page-recorder-main-world.ts b/src/entrypoints/page-recorder-main-world.ts index 7d59ef2..027b7ca 100644 --- a/src/entrypoints/page-recorder-main-world.ts +++ b/src/entrypoints/page-recorder-main-world.ts @@ -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'; @@ -673,7 +674,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 { @@ -863,7 +871,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') { diff --git a/src/features/browser-analysis/service.ts b/src/features/browser-analysis/service.ts index 7d30fe8..99bae56 100644 --- a/src/features/browser-analysis/service.ts +++ b/src/features/browser-analysis/service.ts @@ -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'; @@ -34,6 +35,7 @@ import { type BrowserTransformProfileProposalResult, type BrowserTransformProfileValidationResult, type BrowserTransformValidationDraft, + type BrowserTransformDirectionName, } from '@/types/models'; const MAX_TRACE_EVENTS = 80; @@ -478,6 +480,37 @@ export async function latestBrowserTransformValidation( return draft || memoryValidationDrafts.get(key) || null; } +export async function browserTransformValidationById( + validationId: string, +): Promise { + 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 { + 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 +834,7 @@ export function comparePacketWithInferenceCandidate( check( checks, 'body-shape', - '已关联的线上字段存在且没有二次 JSON 包装', + '线上字段结构一致(不验证加密前的输入内容)', bodyFieldsPresent, actualShape.signature, bodyFields, @@ -1045,6 +1078,7 @@ export async function proposeBrowserTransformProfile( callableId: string, inputPaths?: string[], name?: string, + packet?: BrowserTransformPacket, ): Promise { const [snapshot, callables, tab, frame] = await Promise.all([ getBrowserRecording(target, 500, false), @@ -1082,7 +1116,7 @@ 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); profile = { ...profile, name: name || profile.name, @@ -1129,7 +1163,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 测试,只有复用配置才需要用户在插件中确认保存', }; } @@ -1196,7 +1230,7 @@ export async function validateBrowserTransformProposal( proofLevel, normalizedProfile, generated, - execution, + execution: { ...execution, explanation: normalized.explanation }, comparison, validationDraft: validationDraft ? { contractVersion: validationDraft.contractVersion, @@ -1206,9 +1240,11 @@ export async function validateBrowserTransformProposal( } : 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 +1264,7 @@ export async function validateInferredBrowserTransformProfile( callableId, inputPaths, name, + packet, ); return validateBrowserTransformProposal( proposal.profile, @@ -1237,3 +1274,35 @@ export async function validateInferredBrowserTransformProfile( candidateId, ); } + +export async function prepareCapturedBrowserTransformProfile( + target: BrowserTarget, + candidateId: string, + packet: BrowserTransformPacket, + inputPaths?: string[], + name?: string, +): Promise { + const candidate = await resolveStagedProfileCandidate(target, candidateId); + const source = [candidate.source, ...candidate.sources] + .find((item) => item.callHandleId); + if (!source?.callHandleId) { + throw new ExtensionError( + 'gateway_capture_required', + '本次操作没有捕获到可回放的页面函数,请在原页面重新执行一次浏览器加解密检查', + ); + } + const existing = (await listPageCallables(target)) + .find((item) => item.provenance.eventId === source.eventId); + const callable = existing || await createRecordedPageCallable(target, { + callHandleId: source.callHandleId, + name: name || candidate.summary.slice(0, 120) || 'Captured page transform', + }); + return validateInferredBrowserTransformProfile( + target, + candidate.id, + callable.id, + packet, + inputPaths, + name, + ); +} diff --git a/src/features/browser-crypto/inspect.test.ts b/src/features/browser-crypto/inspect.test.ts new file mode 100644 index 0000000..b544b30 --- /dev/null +++ b/src/features/browser-crypto/inspect.test.ts @@ -0,0 +1,176 @@ +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', + 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('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; + } + }); +}); diff --git a/src/features/browser-crypto/inspect.ts b/src/features/browser-crypto/inspect.ts new file mode 100644 index 0000000..6662f83 --- /dev/null +++ b/src/features/browser-crypto/inspect.ts @@ -0,0 +1,265 @@ +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 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 { + 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 { + 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 { + 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 { + 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> { + 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 | 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); + const preparation = snapshot.profileCandidates + .filter((candidate) => candidate.direction === 'request' && [candidate.source, ...candidate.sources] + .some((source) => Boolean(source.callHandleId))) + .sort((left, right) => right.confidence.score - left.confidence.score)[0]; + 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: 'ready', + candidateId: preparation.id, + direction: preparation.direction, + confidence: preparation.confidence, + request: { + method: preparation.request.method, + url: preparation.request.url, + bodyFormat: preparation.request.bodyFormat, + destinations: preparation.request.mappings.map((mapping) => mapping.destination).filter(Boolean), + }, + next: '需要明文 HTTP 测试时,直接调用 browser.transform.prepare;不要再调用 recording、callable、debugger 或 profile 底层能力', + } : { + state: 'unavailable', + next: '本次证据可用于分析,但不足以生成明文转换;继续使用当前页面,不要重新打开网站', + }, + warnings, + ...evidence, + purpose: '仅分析这一次页面操作;未创建、验证或保存明文网关 Profile', + }; +} diff --git a/src/features/browser-recording/expiry.test.ts b/src/features/browser-recording/expiry.test.ts new file mode 100644 index 0000000..d16b423 --- /dev/null +++ b/src/features/browser-recording/expiry.test.ts @@ -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); + }); +}); diff --git a/src/features/browser-recording/expiry.ts b/src/features/browser-recording/expiry.ts new file mode 100644 index 0000000..10ee41b --- /dev/null +++ b/src/features/browser-recording/expiry.ts @@ -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; +} diff --git a/src/features/browser-transform/profile-draft.test.ts b/src/features/browser-transform/profile-draft.test.ts index 48a9baa..acf6838 100644 --- a/src/features/browser-transform/profile-draft.test.ts +++ b/src/features/browser-transform/profile-draft.test.ts @@ -5,6 +5,7 @@ import type { BrowserProfileInferenceCandidate, } from '@/types/models'; import { createBrowserTransformProfileInput } 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); diff --git a/src/features/browser-transform/profile-draft.ts b/src/features/browser-transform/profile-draft.ts index 2a6a94f..f1b23d8 100644 --- a/src/features/browser-transform/profile-draft.ts +++ b/src/features/browser-transform/profile-draft.ts @@ -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 { @@ -26,7 +28,7 @@ function emptyDirection(enabled = false): BrowserTransformDirection { return { enabled, nodes: [] }; } -function candidateGuidance(candidate?: BrowserProfileInferenceCandidate): { +function candidateGuidance(candidate?: BrowserProfileInferenceCandidate, callable?: BrowserPageCallable, packet?: BrowserTransformPacket): { inputPaths?: string[]; outputKind?: GuidedTransformOutputKind; outputField?: string; @@ -37,7 +39,23 @@ function candidateGuidance(candidate?: BrowserProfileInferenceCandidate): { if (candidate?.direction === 'response') { return { inputPaths: [destination], outputKind: 'body' }; } - if (serialization === 'form-field') return { outputKind: 'form-field', outputField: destination.slice(5) }; + 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 === '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,8 +67,9 @@ export function createBrowserTransformProfileInput( event?: BrowserRecordingEvent, callable?: BrowserPageCallable, candidate?: BrowserProfileInferenceCandidate, + packet?: BrowserTransformPacket, ): 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 ? { diff --git a/src/features/browser-transform/service.ts b/src/features/browser-transform/service.ts index 17f8d0b..6c44457 100644 --- a/src/features/browser-transform/service.ts +++ b/src/features/browser-transform/service.ts @@ -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); diff --git a/src/features/grants/capability-domains.ts b/src/features/grants/capability-domains.ts index a699901..10198a4 100644 --- a/src/features/grants/capability-domains.ts +++ b/src/features/grants/capability-domains.ts @@ -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.'), }; diff --git a/src/features/grants/capability-handlers/recording.ts b/src/features/grants/capability-handlers/recording.ts index f82686b..974355e 100644 --- a/src/features/grants/capability-handlers/recording.ts +++ b/src/features/grants/capability-handlers/recording.ts @@ -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') { diff --git a/src/features/grants/capability-handlers/transform.ts b/src/features/grants/capability-handlers/transform.ts index d4cf46d..3b3da41 100644 --- a/src/features/grants/capability-handlers/transform.ts +++ b/src/features/grants/capability-handlers/transform.ts @@ -1,6 +1,7 @@ import type { BrowserTransformExecuteInput, BrowserTransformPacket, + BrowserTransformValidationExecuteInput, } from '@/types/models'; import type { CapabilityDomainHandler } from '../capability-context'; import { allowedTarget, requireScope } from '../capability-context'; @@ -18,7 +19,10 @@ import { } from '@/features/browser-transform/service'; import { compareBrowserPackets, + browserTransformValidationById, + executeBrowserTransformValidation, latestBrowserTransformValidation, + prepareCapturedBrowserTransformProfile, proposeBrowserTransformProfile, validateInferredBrowserTransformProfile, } from '@/features/browser-analysis/service'; @@ -27,6 +31,17 @@ import { TRANSFORM_CAPABILITY_DOMAIN } from '../capability-domains'; export const transformCapabilityHandler: CapabilityDomainHandler = { ...TRANSFORM_CAPABILITY_DOMAIN, async handle({ method, input, grant }) { + 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, + ); + } if (method === 'browser.packet.compare') { await allowedTarget(grant, input); return compareBrowserPackets( @@ -123,6 +138,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); diff --git a/src/features/page-context/dialogs.ts b/src/features/page-context/dialogs.ts new file mode 100644 index 0000000..b43df47 --- /dev/null +++ b/src/features/page-context/dialogs.ts @@ -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 { + 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 { + 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 []; + } +} diff --git a/src/features/page-context/service.ts b/src/features/page-context/service.ts index 07fefe5..a569577 100644 --- a/src/features/page-context/service.ts +++ b/src/features/page-context/service.ts @@ -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 { - 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 { diff --git a/src/protocol/bridge.test.ts b/src/protocol/bridge.test.ts index e3ca30a..16ea740 100644 --- a/src/protocol/bridge.test.ts +++ b/src/protocol/bridge.test.ts @@ -131,6 +131,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 +169,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 +198,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: {}, diff --git a/src/protocol/bridge.ts b/src/protocol/bridge.ts index e7d71c8..393b5a7 100644 --- a/src/protocol/bridge.ts +++ b/src/protocol/bridge.ts @@ -114,6 +114,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({})), @@ -230,6 +236,13 @@ export const capabilityParams = { observed: v.optional(browserTransformPacketSchema), comparisonMode: v.optional(v.picklist(['structure', 'exact'])), }), + 'browser.transform.prepare': v.strictObject({ + ...targetFields, + candidateId: 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.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)), @@ -252,6 +265,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)), diff --git a/src/protocol/capabilities.test.ts b/src/protocol/capabilities.test.ts index ca73e47..72c1b80 100644 --- a/src/protocol/capabilities.test.ts +++ b/src/protocol/capabilities.test.ts @@ -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, }); @@ -63,6 +75,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', diff --git a/src/protocol/capabilities.ts b/src/protocol/capabilities.ts index 666aa85..8bd7873 100644 --- a/src/protocol/capabilities.ts +++ b/src/protocol/capabilities.ts @@ -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,7 +252,7 @@ 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': { @@ -277,24 +289,34 @@ 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 捕获的候选原子化编译并验证为短时明文转换;不需要 Agent 操作录制、页面函数或 Profile 底层步骤', + scopes: ['browser.transform.execute', 'browser.recording.read', 'browser.callable.execute'], + targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS, + }, 'browser.invoke': { domain: 'page', access: 'dangerous', summary: '在页面 MAIN world 调用具名函数路径', scopes: ['browser.page.invoke'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS, diff --git a/src/types/models.ts b/src/types/models.ts index 9f343ed..5d96b49 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -1271,7 +1271,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; @@ -1732,6 +1740,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 +1756,10 @@ export interface PageNodeDetails extends PageNodeSummary { export interface PageNodeActionResult { action: PageNodeAction; - completedAt: number; + status: 'dispatched'; + dispatchedAt: number; node: PageNodeDetails; + dialogs?: PageDialog[]; } export interface PageEvalRequest {