mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 04:31:52 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881bbc2c89 | ||
|
|
b44e82d8e1 | ||
|
|
a9009c2058 |
@@ -451,10 +451,27 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
|
||||
return ok(engineBridge.getStatus());
|
||||
}
|
||||
case 'bridge.pair': {
|
||||
const status = await engineBridge.startPairing();
|
||||
void appendAuditEvent({ category: 'bridge', action: 'bridge.pair', outcome: 'success' });
|
||||
return ok(status);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const status = await engineBridge.startPairing();
|
||||
void appendAuditEvent({
|
||||
category: 'bridge', action: 'bridge.pair',
|
||||
outcome: status.state === 'rejected'
|
||||
? 'denied'
|
||||
: status.state === 'pending' || status.state === 'approved' ? 'success' : 'error',
|
||||
durationMs: Date.now() - startedAt, summary: `${status.state}: ${status.message}`,
|
||||
});
|
||||
return ok(status);
|
||||
} catch (error) {
|
||||
void appendAuditEvent({
|
||||
category: 'bridge', action: 'bridge.pair', outcome: 'error',
|
||||
durationMs: Date.now() - startedAt, errorCode: errorCode(error),
|
||||
summary: (error instanceof Error ? error.message : String(error)).slice(0, 512),
|
||||
});
|
||||
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': {
|
||||
|
||||
@@ -716,6 +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); }
|
||||
.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; }
|
||||
|
||||
@@ -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<void>, success?: string) => Promise<void>; busy: boolean }) {
|
||||
const [draft, setDraft] = useState(state.bridge);
|
||||
const [pairing, setPairing] = useState<BridgePairingStatus>({ state: 'idle', message: state.bridge.pairedEngine ? '当前浏览器已配对' : '尚未配对' });
|
||||
const [pairing, setPairing] = useState<BridgePairingStatus>({ state: 'idle', message: state.bridge.pairedEngine ? '当前浏览器已配对' : '点击下方按钮查找正在运行的 Yakit' });
|
||||
const [engines, setEngines] = useState<DiscoveredYakEngine[]>([]);
|
||||
const [panelDraft, setPanelDraft] = useState(state.floatingPanel);
|
||||
const [policy, setPolicy] = useState<EnterprisePolicyStatus>({ managed: false, policy: {}, warnings: [] });
|
||||
useEffect(() => {
|
||||
@@ -838,8 +840,27 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }:
|
||||
setState(next);
|
||||
setPanelDraft(next.floatingPanel);
|
||||
}, '悬浮面板策略已保存');
|
||||
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'));
|
||||
}
|
||||
});
|
||||
return <div className="section-view engine-view">
|
||||
<div className="page-heading"><div><h1>Yak 引擎连接</h1><p>扩展主动连接本机 Bridge,网页无法直接访问此通道。</p></div><span className={`large-status ${bridge.state}`}><Radio size={16} />{bridge.message}</span></div>
|
||||
<div className="page-heading"><div><h1>连接 Yakit</h1><p>查找并连接正在本机运行的 Yakit。</p></div><span className={`large-status ${bridge.state}`}><Radio size={16} />{bridge.message}</span></div>
|
||||
{policy.managed && <div className="managed-policy-banner"><ShieldCheck size={16} /><span><strong>此浏览器由组织策略管理</strong><small>{policy.policy.disableWebSocket ? '必须使用 Native Messaging' : policy.policy.bridgeTransport ? `传输锁定为 ${policy.policy.bridgeTransport}` : '连接与授权限制已应用'}{policy.policy.maxGrantMinutes ? ` · 授权最长 ${policy.policy.maxGrantMinutes} 分钟` : ''}{policy.policy.allowProgramEval === false ? ' · 程序 Eval 已禁用' : ''}</small>{policy.warnings.map((warning) => <i key={warning}>{warning}</i>)}</span></div>}
|
||||
{bridge.state === 'connected' && <div className="bridge-identity-strip"><div><span>引擎实例</span><code title={bridge.engineInstanceId}>{bridge.engineInstanceId?.slice(0, 18)}</code></div><div><span>连接</span><code title={bridge.connectionId}>{bridge.connectionId?.slice(0, 18)}</code></div><div><span>会话</span><code title={bridge.sessionId}>{bridge.sessionId?.slice(0, 18)}</code></div><div><span>心跳</span><strong>{bridge.latencyMs === undefined ? '等待首个回执' : `${bridge.latencyMs} ms`}</strong></div><div><span>恢复</span><strong>{bridge.resumed ? '已恢复 task session' : '新会话'}</strong></div></div>}
|
||||
<div className="engine-layout"><div className="settings-form">
|
||||
@@ -847,8 +868,9 @@ function EngineSettings({ state, setState, bridge, setBridge, tabs, run, busy }:
|
||||
<div className="pairing-workspace__heading"><span className="pairing-icon"><KeyRound size={19} /></span><div><h2>{state.bridge.pairedEngine ? '浏览器已安全配对' : pairing.state === 'pending' ? '等待 Yakit 确认' : '连接本机 Yakit'}</h2><p>{state.bridge.pairedEngine ? '设备身份已锁定到首次批准的 Yak 引擎。' : pairing.message}</p></div></div>
|
||||
{pairing.state === 'pending' && <div className="pairing-code" aria-live="polite"><span>配对验证码</span><strong>{pairing.code?.slice(0, 3)} {pairing.code?.slice(3)}</strong><small>{pairing.expiresAt ? `${Math.max(0, Math.ceil((pairing.expiresAt - Date.now()) / 1000))} 秒内有效` : ''}</small></div>}
|
||||
{state.bridge.pairedEngine && <div className="paired-engine-meta"><div><span>引擎身份</span><code title={state.bridge.pairedEngine.engineIdentityId}>{state.bridge.pairedEngine.engineIdentityId.slice(0, 24)}</code></div><div><span>设备 ID</span><code title={state.bridge.pairedEngine.deviceId}>{state.bridge.pairedEngine.deviceId.slice(0, 24)}</code></div></div>}
|
||||
{engines.length > 1 && <div className="engine-discovery-list" aria-live="polite">{engines.map((engine, index) => <div className="engine-discovery-row" key={`${engine.engineIdentityId}:${engine.engineInstanceId}`}><span className="engine-discovery-icon"><Server size={17} /></span><div><strong>本机 Yakit {index + 1}</strong><small>端口 {new URL(engine.endpoint).port}</small></div><Button size="sm" variant="primary" disabled={busy} onClick={() => void run(() => pairWithEngine(engine))}>连接</Button></div>)}</div>}
|
||||
<div className="editor-actions">
|
||||
{!state.bridge.pairedEngine && pairing.state !== 'pending' && <Button variant="primary" disabled={busy || pairing.state === 'requesting'} onClick={() => void run(async () => setPairing(await request('bridge.pair')))}><Power size={16} />{pairing.state === 'requesting' ? '正在查找' : '查找本机 Yakit'}</Button>}
|
||||
{!state.bridge.pairedEngine && pairing.state !== 'pending' && <Button variant="primary" disabled={busy || pairing.state === 'requesting'} onClick={() => void findLocalEngines()}><Search size={16} />{busy ? '正在查找' : '查找本机 Yakit'}</Button>}
|
||||
{!state.bridge.pairedEngine && pairing.state === 'pending' && <Button disabled={busy} onClick={() => void run(async () => setPairing(await request('bridge.pair.cancel')))}><X size={16} />取消申请</Button>}
|
||||
{state.bridge.pairedEngine && <Button variant="primary" disabled={busy} onClick={() => void run(async () => { if (bridge.state === 'connected') await request('bridge.disconnect'); else await request('bridge.connect'); setBridge(await request('bridge.status')); }, bridge.state === 'connected' ? 'Bridge 已断开' : 'Bridge 正在连接')}><Power size={16} />{bridge.state === 'connected' ? '断开连接' : '连接引擎'}</Button>}
|
||||
{state.bridge.pairedEngine && <Button variant="ghost" disabled={busy} onClick={() => { if (window.confirm('解除当前 Yak 引擎的本地配对?浏览器安装身份会保留,重新配对时 Yakit 将更新原可信记录。')) void run(async () => { const next = await request('bridge.unpair'); setState(next); setDraft(next.bridge); }, '本地配对凭据已清除'); }}><Trash2 size={16} />解除配对</Button>}
|
||||
|
||||
@@ -110,7 +110,6 @@ function App() {
|
||||
|
||||
const toggleEngine = () => run(async () => {
|
||||
if (!state!.bridge.pairedEngine) {
|
||||
await request('bridge.pair');
|
||||
await openTool('engine');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<DiscoveredYakEngine[]> {
|
||||
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<DiscoveredYakEngine | undefined>((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 });
|
||||
|
||||
+27
-1
@@ -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<string, unknown>;
|
||||
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));
|
||||
|
||||
@@ -487,6 +487,7 @@ const payloadSchemas = {
|
||||
}, '启动代理只能包含协议、主机和端口'))])),
|
||||
}),
|
||||
'bridge.pair': noPayload,
|
||||
'bridge.discover': noPayload,
|
||||
'bridge.pair.cancel': noPayload,
|
||||
'bridge.pair.status': noPayload,
|
||||
'bridge.unpair': noPayload,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user