From 881bbc2c8973f2a0262977821401d039e231328e Mon Sep 17 00:00:00 2001 From: go0p Date: Thu, 24 Sep 2026 17:28:40 +0800 Subject: [PATCH] fix: choose local yak engine before pairing --- src/app/background/index.ts | 6 +-- src/entrypoints/options/App.css | 11 +++-- src/entrypoints/options/App.tsx | 50 +++++++++++----------- src/entrypoints/popup/App.tsx | 1 - src/features/diagnostics/export.ts | 17 +------- src/features/engine-bridge/service.test.ts | 27 ++++++++++++ src/features/engine-bridge/service.ts | 45 ++++++++++++++++++- src/protocol/bridge.ts | 28 +++++++++++- src/protocol/extension.ts | 1 + src/types/messages.ts | 2 + src/types/models.ts | 16 +++---- 11 files changed, 140 insertions(+), 64 deletions(-) diff --git a/src/app/background/index.ts b/src/app/background/index.ts index 6cb75c9..eba1b29 100644 --- a/src/app/background/index.ts +++ b/src/app/background/index.ts @@ -409,10 +409,7 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. } case 'agent.actions.clear': return ok(await clearAgentActions()); case 'policy.status': return ok(await getEnterprisePolicy()); - case 'diagnostics.export': return ok(await createDiagnosticsBundle( - engineBridge.getStatus(), - engineBridge.getPairingStatus(), - )); + case 'diagnostics.export': return ok(await createDiagnosticsBundle(engineBridge.getStatus())); case 'metrics.get': return ok(await getRuntimeMetrics()); case 'metrics.reset': return ok(await resetRuntimeMetrics()); case 'bridge.config.save': { @@ -474,6 +471,7 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. throw error; } } + case 'bridge.discover': return ok(await engineBridge.discoverLocalEngines()); case 'bridge.pair.cancel': return ok(engineBridge.cancelPairing()); case 'bridge.pair.status': return ok(engineBridge.getPairingStatus()); case 'bridge.unpair': { diff --git a/src/entrypoints/options/App.css b/src/entrypoints/options/App.css index 5afd668..04a204b 100644 --- a/src/entrypoints/options/App.css +++ b/src/entrypoints/options/App.css @@ -716,10 +716,11 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; .pairing-code span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .05em; text-transform: uppercase; } .pairing-code strong { font-family: var(--font-mono); font-size: 30px; font-weight: 700; letter-spacing: .12em; } .pairing-code small { color: var(--muted); font-size: var(--text-sm); } -.pairing-diagnostics { padding: 10px 12px; display: grid; grid-template-columns: minmax(90px, .35fr) minmax(0, 1fr) auto; gap: 12px; align-items: center; border-radius: var(--radius-md); background: var(--surface-subtle); text-align: left; } -.pairing-diagnostics > div { min-width: 0; } -.pairing-diagnostics span { display: block; color: var(--muted); font-size: var(--text-xs); } -.pairing-diagnostics code { display: block; margin-top: 2px; overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; } +.engine-discovery-list { display: grid; gap: 8px; } +.engine-discovery-row { padding: 10px 12px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 10px; align-items: center; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-subtle); text-align: left; } +.engine-discovery-icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; color: var(--accent-strong); background: var(--accent-soft); } +.engine-discovery-row strong, .engine-discovery-row small { display: block; } +.engine-discovery-row small { margin-top: 2px; color: var(--muted); } .paired-engine-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .paired-engine-meta > div { min-width: 0; } .paired-engine-meta span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } @@ -799,8 +800,6 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; .ua-assignment-list code { display: none; } .task-command-bar, .agent-runtime-summary { flex-direction: column; display: flex; align-items: stretch; } .task-status-grid, .context-session-strip, .diff-summary, .context-inventory-grid, .grant-options, .panel-policy-grid, .form-grid, .paired-engine-meta { grid-template-columns: minmax(0, 1fr); } - .pairing-diagnostics { grid-template-columns: minmax(0, 1fr); } - .pairing-diagnostics .ui-button { justify-self: start; } .agent-action-row { grid-template-columns: 12px 76px minmax(0, 1fr) 76px; } .agent-action-row span:nth-child(4), .agent-action-row span:last-child { display: none; } .activity-table__head, .activity-table__row { grid-template-columns: 120px minmax(0, 1fr) 80px; } diff --git a/src/entrypoints/options/App.tsx b/src/entrypoints/options/App.tsx index 7390fe6..585165a 100644 --- a/src/entrypoints/options/App.tsx +++ b/src/entrypoints/options/App.tsx @@ -23,6 +23,7 @@ import { AuthorizationTestingWorkspace } from '@/features/authorization-testing/ import { AGENT_RUNTIME_STORAGE_KEY, AUDIT_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage'; import type { ActiveTabInfo, AgentRuntime, AuditEvent, BridgePairingStatus, BridgeStatus, BrowserCookie, BrowserRequestAnalysisBundle, CookieInput, CookieTransferFormat, EnterprisePolicyStatus, ExtensionState, HumanHandoff, + DiscoveredYakEngine, NetworkCaptureStatus, NetworkRequestExport, NetworkRequestRecord, PageContext, PageEvalResult, PageFrameSummary, PageNodeDetails, PageNodeSummary, UserAgentProfile, UserAgentProfileInput, YakPocGenerateResult, @@ -810,7 +811,8 @@ function ContextTool({ tab, run, busy }: { tab?: ActiveTabInfo; run: (task: () = function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }: { state: ExtensionState; setState: (state: ExtensionState) => void; bridge: BridgeStatus; setBridge: (status: BridgeStatus) => void; tabs: ActiveTabInfo[]; run: (task: () => Promise, success?: string) => Promise; busy: boolean }) { const [draft, setDraft] = useState(state.bridge); - const [pairing, setPairing] = useState({ state: 'idle', message: state.bridge.pairedEngine ? '当前浏览器已配对' : '尚未配对' }); + const [pairing, setPairing] = useState({ state: 'idle', message: state.bridge.pairedEngine ? '当前浏览器已配对' : '点击下方按钮查找正在运行的 Yakit' }); + const [engines, setEngines] = useState([]); const [panelDraft, setPanelDraft] = useState(state.floatingPanel); const [policy, setPolicy] = useState({ managed: false, policy: {}, warnings: [] }); useEffect(() => { @@ -838,31 +840,27 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }: setState(next); setPanelDraft(next.floatingPanel); }, '悬浮面板策略已保存'); - const pairingEndpoint = (() => { - try { - const value = new URL(state.bridge.endpoint); - value.pathname = '/pairing'; - value.search = ''; - value.hash = ''; - return value.toString(); - } catch { - return state.bridge.endpoint; + const pairWithEngine = async (engine: DiscoveredYakEngine) => { + const next = await request('bridge.config.save', { ...state.bridge, transport: 'websocket', endpoint: engine.endpoint }); + setState(next); + setDraft(next.bridge); + setEngines([]); + setPairing(await request('bridge.pair')); + }; + const findLocalEngines = () => run(async () => { + setEngines([]); + const found = await request('bridge.discover'); + if (found.length === 1) { + await pairWithEngine(found[0]); + } else if (found.length > 1) { + setEngines(found); + setPairing({ state: 'idle', message: `发现 ${found.length} 个正在运行的 Yakit,请选择一个连接` }); + } else { + setPairing(await request('bridge.pair')); } - })(); - const copyPairingDiagnostics = () => run(async () => { - const { code, ...safePairing } = pairing; - await navigator.clipboard.writeText(JSON.stringify({ - extensionVersion: browser.runtime.getManifest().version, - pairing: { ...safePairing, hasVerificationCode: Boolean(code) }, - bridge, - transport: state.bridge.transport, - endpoint: pairingEndpoint, - paired: Boolean(state.bridge.pairedEngine), - managedInstance: state.bridge.managedInstance, - }, null, 2)); - }, '配对排查信息已复制'); + }); return
-

Yak 引擎连接

扩展主动连接本机 Bridge,网页无法直接访问此通道。

{bridge.message}
+

连接 Yakit

查找并连接正在本机运行的 Yakit。

{bridge.message}
{policy.managed &&
此浏览器由组织策略管理{policy.policy.disableWebSocket ? '必须使用 Native Messaging' : policy.policy.bridgeTransport ? `传输锁定为 ${policy.policy.bridgeTransport}` : '连接与授权限制已应用'}{policy.policy.maxGrantMinutes ? ` · 授权最长 ${policy.policy.maxGrantMinutes} 分钟` : ''}{policy.policy.allowProgramEval === false ? ' · 程序 Eval 已禁用' : ''}{policy.warnings.map((warning) => {warning})}
} {bridge.state === 'connected' &&
引擎实例{bridge.engineInstanceId?.slice(0, 18)}
连接{bridge.connectionId?.slice(0, 18)}
会话{bridge.sessionId?.slice(0, 18)}
心跳{bridge.latencyMs === undefined ? '等待首个回执' : `${bridge.latencyMs} ms`}
恢复{bridge.resumed ? '已恢复 task session' : '新会话'}
}
@@ -870,9 +868,9 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }:

{state.bridge.pairedEngine ? '浏览器已安全配对' : pairing.state === 'pending' ? '等待 Yakit 确认' : '连接本机 Yakit'}

{state.bridge.pairedEngine ? '设备身份已锁定到首次批准的 Yak 引擎。' : pairing.message}

{pairing.state === 'pending' &&
配对验证码{pairing.code?.slice(0, 3)} {pairing.code?.slice(3)}{pairing.expiresAt ? `${Math.max(0, Math.ceil((pairing.expiresAt - Date.now()) / 1000))} 秒内有效` : ''}
} {state.bridge.pairedEngine &&
引擎身份{state.bridge.pairedEngine.engineIdentityId.slice(0, 24)}
设备 ID{state.bridge.pairedEngine.deviceId.slice(0, 24)}
} -
当前阶段{state.bridge.pairedEngine ? 'paired' : pairing.state}
配对地址{pairingEndpoint}
+ {engines.length > 1 &&
{engines.map((engine, index) =>
本机 Yakit {index + 1}端口 {new URL(engine.endpoint).port}
)}
}
- {!state.bridge.pairedEngine && pairing.state !== 'pending' && } + {!state.bridge.pairedEngine && pairing.state !== 'pending' && } {!state.bridge.pairedEngine && pairing.state === 'pending' && } {state.bridge.pairedEngine && } {state.bridge.pairedEngine && } diff --git a/src/entrypoints/popup/App.tsx b/src/entrypoints/popup/App.tsx index 11f7c51..3edf334 100644 --- a/src/entrypoints/popup/App.tsx +++ b/src/entrypoints/popup/App.tsx @@ -110,7 +110,6 @@ function App() { const toggleEngine = () => run(async () => { if (!state!.bridge.pairedEngine) { - void request('bridge.pair').catch(() => undefined); await openTool('engine'); return; } diff --git a/src/features/diagnostics/export.ts b/src/features/diagnostics/export.ts index cb1becb..0e698bd 100644 --- a/src/features/diagnostics/export.ts +++ b/src/features/diagnostics/export.ts @@ -1,15 +1,12 @@ import { browser } from 'wxt/browser'; import { STATE_STORAGE_KEYS } from '@/protocol/storage'; -import type { BridgePairingStatus, BridgeStatus, DiagnosticsBundle } from '@/types/models'; +import type { BridgeStatus, DiagnosticsBundle } from '@/types/models'; import { listAuditEvents } from '@/features/diagnostics/audit'; import { getState } from '@/platform/storage/state'; import { getEnterprisePolicy } from '@/platform/policy/managed'; import { getRuntimeMetrics } from './metrics'; -export async function createDiagnosticsBundle( - bridge: BridgeStatus, - pairing: BridgePairingStatus, -): Promise { +export async function createDiagnosticsBundle(bridge: BridgeStatus): Promise { const manifest = browser.runtime.getManifest(); const sessionArea = (browser.storage as unknown as { session?: { get(keys: string[]): Promise> } }).session; const [state, platform, policy, metrics, audit, local, session] = await Promise.all([ @@ -18,7 +15,6 @@ export async function createDiagnosticsBundle( sessionArea?.get([...STATE_STORAGE_KEYS]) || Promise.resolve({}), ]); const { taskId: _taskId, grantId: _grantId, ...safeBridge } = bridge; - const { code, ...safePairing } = pairing; return { schemaVersion: 1, generatedAt: Date.now(), @@ -30,15 +26,6 @@ export async function createDiagnosticsBundle( }, platform: { os: platform.os, arch: platform.arch }, bridge: safeBridge, - pairing: { ...safePairing, hasVerificationCode: Boolean(code) }, - bridgeConfiguration: { - transport: state.bridge.transport, - endpoint: state.bridge.endpoint, - nativeHost: state.bridge.nativeHost, - autoConnect: state.bridge.autoConnect, - paired: Boolean(state.bridge.pairedEngine), - managedInstance: state.bridge.managedInstance, - }, policy, state: { proxyProfiles: state.proxyProfiles.length, diff --git a/src/features/engine-bridge/service.test.ts b/src/features/engine-bridge/service.test.ts index b5653fc..cb476e1 100644 --- a/src/features/engine-bridge/service.test.ts +++ b/src/features/engine-bridge/service.test.ts @@ -396,6 +396,33 @@ describe('Engine Bridge transport lifecycle', () => { }); }); + it('finds every local Yak engine in the bridge port range', async () => { + fixture.state.bridge = bridgeConfig(false); + const bridge = new EngineBridge(); + const pending = bridge.discoverLocalEngines(); + await Promise.resolve(); + await Promise.resolve(); + + expect(FakeWebSocket.instances).toHaveLength(16); + for (const index of [0, 2]) { + const socket = FakeWebSocket.instances[index]; + socket.open(); + socket.receive({ + type: 'engine', + protocolVersion: BRIDGE_PROTOCOL_VERSION, + engineIdentityId: `engine-identity-${index}`, + engineInstanceId: `engine-instance-${index}`, + endpoint: `ws://127.0.0.1:${64333 + index}/extension`, + }); + } + await vi.advanceTimersByTimeAsync(801); + + await expect(pending).resolves.toEqual([ + expect.objectContaining({ endpoint: 'ws://127.0.0.1:64333/extension' }), + expect.objectContaining({ endpoint: 'ws://127.0.0.1:64335/extension' }), + ]); + }); + it('uses the engine pairing deadline locally after pair_pending', async () => { fixture.state.bridge = bridgeConfig(false); const bridge = new EngineBridge(); diff --git a/src/features/engine-bridge/service.ts b/src/features/engine-bridge/service.ts index 9d3792c..177bcc5 100644 --- a/src/features/engine-bridge/service.ts +++ b/src/features/engine-bridge/service.ts @@ -1,6 +1,6 @@ import { browser } from 'wxt/browser'; import type { BridgeEnvelope } from '@/types/messages'; -import type { BridgeConfig, BridgePairingStatus, BridgePublicKey, BridgeStatus } from '@/types/models'; +import type { BridgeConfig, BridgePairingStatus, BridgePublicKey, BridgeStatus, DiscoveredYakEngine } from '@/types/models'; import { BRIDGE_CAPABILITIES, capabilityVisibleToAgent, @@ -9,7 +9,7 @@ import { import { BRIDGE_CHUNK_BYTES, BRIDGE_CHUNK_THRESHOLD_BYTES, BRIDGE_CHUNK_TIMEOUT_MS, BRIDGE_MAX_CHUNK_TRANSFERS, BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_PROTOCOL_VERSION, parseBridgeEnvelope, - parseBridgePairingEnvelope, type BridgePairingEnvelope, + parseBridgeDiscoveryEnvelope, parseBridgePairingEnvelope, type BridgePairingEnvelope, } from '@/protocol/bridge'; import { getBridgeRuntimeSession, getState, setBridgeRuntimeSession, updateState } from '@/platform/storage/state'; import { routeCapability } from '@/features/grants/service'; @@ -32,6 +32,8 @@ const HANDSHAKE_TIMEOUT = 5_000; const MAX_CONCURRENT_REQUESTS = 8; const ENGINE_REQUEST_TIMEOUT = 10_000; const MAX_OUTGOING_REQUESTS = 4; +const DISCOVERY_PORTS = 16; +const DISCOVERY_TIMEOUT = 800; export function browserClientIdentity( config: BridgeConfig, @@ -136,6 +138,45 @@ export class EngineBridge { return this.pairingStatus; } + async discoverLocalEngines(): Promise { + const config = (await getState()).bridge; + if (config.transport !== 'websocket' || !isLoopbackEndpoint(config.endpoint)) return []; + const configured = new URL(config.endpoint); + const firstPort = Number(configured.port) || (configured.protocol === 'wss:' ? 443 : 80); + const candidates = Array.from( + { length: Math.min(DISCOVERY_PORTS, 65_536 - firstPort) }, + (_, offset) => { + const url = new URL(configured); + url.port = String(firstPort + offset); + url.pathname = '/discovery'; + url.search = ''; + url.hash = ''; + return url.toString(); + }, + ); + const results = await Promise.all(candidates.map((url) => new Promise((resolve) => { + let socket: WebSocket; + try { socket = new WebSocket(url); } catch { resolve(undefined); return; } + let settled = false; + const finish = (engine?: DiscoveredYakEngine) => { + if (settled) return; + settled = true; + globalThis.clearTimeout(timer); + if (socket.readyState !== WebSocket.CLOSED) socket.close(); + resolve(engine); + }; + const timer = globalThis.setTimeout(() => finish(), DISCOVERY_TIMEOUT); + socket.addEventListener('message', (event) => { + try { finish(parseBridgeDiscoveryEnvelope(String(event.data))); } catch { finish(); } + }); + socket.addEventListener('error', () => finish()); + socket.addEventListener('close', () => finish()); + }))); + const engines = results.filter((engine): engine is DiscoveredYakEngine => Boolean(engine)); + return [...new Map(engines.map((engine) => [`${engine.engineIdentityId}:${engine.engineInstanceId}`, engine])).values()] + .sort((left, right) => Number(new URL(left.endpoint).port) - Number(new URL(right.endpoint).port)); + } + emitEvent(method: string, params: unknown): void { if (this.status.state !== 'connected') return; this.trySend({ type: 'event', method, params }); diff --git a/src/protocol/bridge.ts b/src/protocol/bridge.ts index 112e025..668a3bf 100644 --- a/src/protocol/bridge.ts +++ b/src/protocol/bridge.ts @@ -1,6 +1,6 @@ import * as v from 'valibot'; import type { BridgeEnvelope } from '@/types/messages'; -import type { BridgePublicKey } from '@/types/models'; +import type { BridgePublicKey, DiscoveredYakEngine } from '@/types/models'; import { browserTransformExecuteSchema, browserTransformPacketSchema, @@ -32,6 +32,32 @@ export interface BridgePairingEnvelope { message?: string; } +export function parseBridgeDiscoveryEnvelope(raw: unknown): DiscoveredYakEngine { + const input = typeof raw === 'string' ? JSON.parse(raw) as unknown : raw; + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Yak 引擎发现消息无效'); + const message = input as Record; + const allowed = new Set(['type', 'protocolVersion', 'engineIdentityId', 'engineInstanceId', 'endpoint']); + const unexpected = Object.keys(message).find((key) => !allowed.has(key)); + if (unexpected) throw new Error(`Yak 引擎发现消息包含未声明字段 $.${unexpected}`); + if (message.type !== 'engine' || message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error('Yak 引擎版本不兼容'); + for (const key of ['engineIdentityId', 'engineInstanceId'] as const) { + if (typeof message[key] !== 'string' || !message[key] || message[key].length > 160) throw new Error(`Yak 引擎 ${key} 无效`); + } + if (typeof message.endpoint !== 'string' || message.endpoint.length > 2_048) throw new Error('Yak 引擎地址无效'); + const endpoint = new URL(message.endpoint); + if (!['ws:', 'wss:'].includes(endpoint.protocol) + || !['127.0.0.1', 'localhost', '[::1]', '::1'].includes(endpoint.hostname) + || endpoint.pathname !== '/extension' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) { + throw new Error('Yak 引擎地址必须是本机 Bridge'); + } + return { + protocolVersion: message.protocolVersion, + engineIdentityId: message.engineIdentityId, + engineInstanceId: message.engineInstanceId, + endpoint: message.endpoint, + } as DiscoveredYakEngine; +} + const id = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)); const sha256Fingerprint = v.pipe(v.string(), v.regex(/^sha256:[a-f0-9]{64}$/)); const tabId = v.pipe(v.number(), v.safeInteger(), v.minValue(1)); diff --git a/src/protocol/extension.ts b/src/protocol/extension.ts index 6467cc8..a2dd6a0 100644 --- a/src/protocol/extension.ts +++ b/src/protocol/extension.ts @@ -487,6 +487,7 @@ const payloadSchemas = { }, '启动代理只能包含协议、主机和端口'))])), }), 'bridge.pair': noPayload, + 'bridge.discover': noPayload, 'bridge.pair.cancel': noPayload, 'bridge.pair.status': noPayload, 'bridge.unpair': noPayload, diff --git a/src/types/messages.ts b/src/types/messages.ts index 2b0a90d..8fa8ae6 100644 --- a/src/types/messages.ts +++ b/src/types/messages.ts @@ -6,6 +6,7 @@ import type { BridgeStatus, BridgeConfig, BridgePairingStatus, + DiscoveredYakEngine, BridgePublicKey, CapabilityScope, BrowserCookie, @@ -258,6 +259,7 @@ export interface ExtensionRequestMap { output: BridgeStatus; }; 'bridge.pair': { input: undefined; output: BridgePairingStatus }; + 'bridge.discover': { input: undefined; output: DiscoveredYakEngine[] }; 'bridge.pair.cancel': { input: undefined; output: BridgePairingStatus }; 'bridge.pair.status': { input: undefined; output: BridgePairingStatus }; 'bridge.unpair': { input: undefined; output: ExtensionState }; diff --git a/src/types/models.ts b/src/types/models.ts index 3080938..6cf0050 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -246,6 +246,13 @@ export interface BridgePairingStatus { expiresAt?: number; } +export interface DiscoveredYakEngine { + protocolVersion: number; + engineIdentityId: string; + engineInstanceId: string; + endpoint: string; +} + export type CapabilityScope = | 'browser.tabs.read' | 'browser.tabs.write' @@ -1482,15 +1489,6 @@ export interface DiagnosticsBundle { extension: { version: string; manifestVersion: number; buildChannel: string; permissions: string[] }; platform: { os: string; arch: string }; bridge: Omit; - pairing: Omit & { hasVerificationCode: boolean }; - bridgeConfiguration: { - transport: BridgeConfig['transport']; - endpoint: string; - nativeHost: string; - autoConnect: boolean; - paired: boolean; - managedInstance?: BridgeConfig['managedInstance']; - }; policy: EnterprisePolicyStatus; state: { proxyProfiles: number;