feat: advance browser agent integration workflows

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent af5a4db694
commit 0c8e1c7b69
215 changed files with 35137 additions and 5442 deletions
+9 -2
View File
@@ -11,12 +11,19 @@ describe('Bridge v3 identity transcript', () => {
})).toBe('yak-browser-bridge-v3\nengine-challenge\nidentity-1\ninstance-1\nnonce-1\n123');
const envelope: BridgeEnvelope = {
type: 'auth', installationId: 'install-1', client: 'client-1', version: '1.0.0',
capabilities: ['z.capability', 'a.capability'], taskId: 'task-1', grantId: 'grant-1', resumeSessionId: 'session-1',
capabilities: ['z.capability', 'a.capability'],
capabilityCatalog: {
version: 1,
schemaDialect: 'http://json-schema.org/draft-07/schema#',
hash: 'schema-hash-1',
capabilities: [],
},
taskId: 'task-1', grantId: 'grant-1', resumeSessionId: 'session-1',
};
expect(clientAuthPayload({
origin: 'chrome-extension://abc', engineIdentityId: 'identity-1', engineInstanceId: 'instance-1',
challenge: 'nonce-1', envelope,
})).toBe('yak-browser-bridge-v3\nclient-auth\nchrome-extension://abc\nidentity-1\ninstance-1\nnonce-1\ninstall-1\nclient-1\n1.0.0\na.capability,z.capability\ntask-1\ngrant-1\nsession-1');
})).toBe('yak-browser-bridge-v3\nclient-auth\nchrome-extension://abc\nidentity-1\ninstance-1\nnonce-1\ninstall-1\nclient-1\n1.0.0\na.capability,z.capability\n1\nschema-hash-1\ntask-1\ngrant-1\nsession-1');
});
it('matches the shared pairing verification vector', async () => {
+4 -1
View File
@@ -137,7 +137,10 @@ export function clientAuthPayload(input: {
return [
'yak-browser-bridge-v3', 'client-auth', input.origin, input.engineIdentityId, input.engineInstanceId,
input.challenge, input.envelope.installationId || '', input.envelope.client || '', input.envelope.version || '',
[...(input.envelope.capabilities || [])].sort().join(','), input.envelope.taskId || '', input.envelope.grantId || '',
[...(input.envelope.capabilities || [])].sort().join(','),
String(input.envelope.capabilityCatalog?.version || ''),
input.envelope.capabilityCatalog?.hash || '',
input.envelope.taskId || '', input.envelope.grantId || '',
input.envelope.resumeSessionId || '',
].join('\n');
}
+458
View File
@@ -0,0 +1,458 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type SocketListener = (event: { data?: unknown }) => void;
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: FakeWebSocket[] = [];
readonly url: string;
readonly sent: string[] = [];
readyState = FakeWebSocket.CONNECTING;
throwOnSend = false;
closeInfo?: { code?: number; reason?: string };
private readonly listeners = new Map<string, SocketListener[]>();
constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}
addEventListener(type: string, listener: SocketListener): void {
const listeners = this.listeners.get(type) || [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
send(data: string): void {
if (this.throwOnSend || this.readyState !== FakeWebSocket.OPEN) throw new Error('socket send failed');
this.sent.push(data);
}
close(code?: number, reason?: string): void {
if (this.readyState === FakeWebSocket.CLOSED) return;
this.readyState = FakeWebSocket.CLOSED;
this.closeInfo = { code, reason };
this.emit('close', {});
}
open(): void {
this.readyState = FakeWebSocket.OPEN;
this.emit('open', {});
}
receive(message: unknown): void {
this.emit('message', { data: typeof message === 'string' ? message : JSON.stringify(message) });
}
private emit(type: string, event: { data?: unknown }): void {
for (const listener of this.listeners.get(type) || []) listener(event);
}
}
const fixture = vi.hoisted(() => ({
state: {} as Record<string, any>,
runtimeSession: undefined as Record<string, unknown> | undefined,
updateStateFailure: undefined as Error | undefined,
setRuntimeSession: vi.fn(async (_value: unknown) => undefined),
appendAudit: vi.fn(async (_value: unknown) => undefined),
pairingCode: vi.fn(async () => '123456'),
}));
vi.mock('wxt/browser', () => ({
browser: {
runtime: {
getManifest: () => ({ version: '0.2.0' }),
getURL: (path = '') => `chrome-extension://fixture-id/${path}`,
sendMessage: vi.fn(async () => undefined),
connectNative: vi.fn(),
lastError: undefined,
},
},
}));
vi.mock('@/platform/storage/state', () => ({
getState: vi.fn(async () => structuredClone(fixture.state)),
updateState: vi.fn(async (updater: (state: Record<string, any>) => Record<string, any> | Promise<Record<string, any>>) => {
if (fixture.updateStateFailure) throw fixture.updateStateFailure;
fixture.state = structuredClone(await updater(structuredClone(fixture.state)));
return structuredClone(fixture.state);
}),
getBridgeRuntimeSession: vi.fn(async () => fixture.runtimeSession),
setBridgeRuntimeSession: fixture.setRuntimeSession,
}));
vi.mock('@/protocol/capabilities', () => ({
BRIDGE_CAPABILITIES: [],
getBridgeCapabilityCatalog: vi.fn(async () => ({ version: 1, capabilities: [] })),
}));
vi.mock('@/features/grants/service', () => ({
routeCapability: vi.fn(async () => ({ ok: true })),
}));
vi.mock('@/features/grants/lifecycle', () => ({
currentActiveGrant: vi.fn(async () => undefined),
}));
vi.mock('@/features/agent-runtime/service', () => ({
beginAgentAction: vi.fn(async () => ({ id: 'action-1' })),
finishAgentAction: vi.fn(async () => undefined),
}));
vi.mock('@/features/diagnostics/audit', () => ({
appendAuditEvent: fixture.appendAudit,
}));
vi.mock('@/features/diagnostics/metrics', () => ({
recordBridgeState: vi.fn(),
recordCapabilityMetric: vi.fn(),
recordHeartbeat: vi.fn(),
}));
vi.mock('./identity', () => ({
clearBrowserBridgeIdentity: vi.fn(async () => undefined),
clientAuthPayload: vi.fn(() => 'client-auth'),
engineChallengePayload: vi.fn(() => 'engine-challenge'),
getOrCreateBrowserBridgeIdentity: vi.fn(async () => ({
publicKey: { kty: 'EC', crv: 'P-256', x: 'browser-x', y: 'browser-y' },
privateKey: {},
})),
pairingVerificationCode: fixture.pairingCode,
publicKeysEqual: vi.fn((left, right) => JSON.stringify(left) === JSON.stringify(right)),
randomBridgeNonce: vi.fn(() => 'browser-client-nonce-0123456789'),
signBridgePayload: vi.fn(async () => 'signature'),
verifyBridgePayload: vi.fn(async () => true),
}));
vi.stubGlobal('WebSocket', FakeWebSocket);
import {
BRIDGE_HEARTBEAT_TIMEOUT_MS,
EngineBridge,
} from './service';
import {
BRIDGE_CHUNK_TIMEOUT_MS,
BRIDGE_PROTOCOL_VERSION,
} from '@/protocol/bridge';
const NOW = 4_102_444_800_000;
const enginePublicKey = { kty: 'EC' as const, crv: 'P-256' as const, x: 'engine-x', y: 'engine-y' };
function bridgeConfig(paired = true) {
return {
transport: 'websocket' as const,
nativeHost: 'com.yaklang.browser_agent',
endpoint: 'ws://127.0.0.1:64333/extension',
autoConnect: false,
installationId: 'installation-1',
pairedEngine: paired ? {
engineIdentityId: 'engine-identity-1',
deviceId: 'device-1',
publicKey: enginePublicKey,
pairedAt: NOW - 1_000,
} : undefined,
};
}
function helloAck() {
return {
type: 'hello_ack',
protocolVersion: BRIDGE_PROTOCOL_VERSION,
version: '1.4.0',
capabilities: ['test.echo'],
sessionId: 'session-1',
engineInstanceId: 'engine-instance-1',
engineIdentityId: 'engine-identity-1',
connectionId: 'connection-1',
resumed: false,
};
}
async function connect(bridge: EngineBridge): Promise<FakeWebSocket> {
const pending = bridge.connect(fixture.state.bridge);
const socket = FakeWebSocket.instances.at(-1)!;
socket.open();
socket.receive({
type: 'challenge',
protocolVersion: BRIDGE_PROTOCOL_VERSION,
engineIdentityId: 'engine-identity-1',
engineInstanceId: 'engine-instance-1',
challenge: 'engine-challenge-0123456789',
signature: 'engine-signature',
timestamp: Date.now(),
publicKey: enginePublicKey,
});
await vi.advanceTimersByTimeAsync(0);
socket.receive(helloAck());
await pending;
return socket;
}
describe('Engine Bridge transport lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
FakeWebSocket.instances.length = 0;
fixture.runtimeSession = undefined;
fixture.updateStateFailure = undefined;
fixture.state = { bridge: bridgeConfig(true), activeGrant: undefined };
vi.clearAllMocks();
fixture.setRuntimeSession.mockResolvedValue(undefined);
fixture.pairingCode.mockResolvedValue('123456');
});
afterEach(() => vi.useRealTimers());
it('rejects an outgoing request immediately when send races with socket failure', async () => {
const bridge = new EngineBridge();
const socket = await connect(bridge);
socket.throwOnSend = true;
await expect(bridge.requestEngine('test.echo', { value: 1 }, 60_000))
.rejects.toMatchObject({ code: 'bridge_disconnected' });
expect(bridge.getStatus()).toMatchObject({ state: 'error', message: expect.stringContaining('发送失败') });
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
});
it('closes a half-open connection and rejects pending calls after missed heartbeats', async () => {
const bridge = new EngineBridge();
const socket = await connect(bridge);
const outgoing = bridge.requestEngine('test.echo', { value: 1 }, 120_000);
const rejection = expect(outgoing).rejects.toMatchObject({ code: 'bridge_disconnected' });
await vi.advanceTimersByTimeAsync(BRIDGE_HEARTBEAT_TIMEOUT_MS + 1);
await rejection;
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
expect(bridge.getStatus()).toMatchObject({
state: 'error',
message: expect.stringContaining('心跳'),
});
});
it('accepts only a matching sent heartbeat as liveness evidence', async () => {
const bridge = new EngineBridge();
const socket = await connect(bridge);
const firstPing = JSON.parse(socket.sent.find((item) => JSON.parse(item).type === 'ping')!);
socket.receive({ type: 'pong', sequence: firstPing.sequence, timestamp: firstPing.timestamp, id: 'wrong-id' });
await vi.advanceTimersByTimeAsync(30_000);
socket.receive({ type: 'pong', sequence: firstPing.sequence, timestamp: firstPing.timestamp, id: firstPing.id });
await vi.advanceTimersByTimeAsync(BRIDGE_HEARTBEAT_TIMEOUT_MS - 1);
expect(bridge.getStatus().state).toBe('connected');
});
it('reconnects after a heartbeat failure when auto-connect is enabled', async () => {
fixture.state.bridge.autoConnect = true;
const bridge = new EngineBridge();
await connect(bridge);
await vi.advanceTimersByTimeAsync(BRIDGE_HEARTBEAT_TIMEOUT_MS + 3_001);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(bridge.getStatus().state).toBe('connecting');
bridge.disconnect();
});
it('expires incomplete chunks independently when no later chunk arrives', async () => {
const bridge = new EngineBridge();
const socket = await connect(bridge);
const lastByte = btoa('x');
for (let index = 0; index < 8; index += 1) {
socket.receive({
type: 'chunk',
transferId: `transfer-${index}`,
index: 1,
total: 2,
originalBytes: 262_145,
data: lastByte,
});
}
expect((bridge as unknown as { chunks: Map<string, unknown> }).chunks.size).toBe(8);
await vi.advanceTimersByTimeAsync(BRIDGE_CHUNK_TIMEOUT_MS + 1);
expect((bridge as unknown as { chunks: Map<string, unknown> }).chunks.size).toBe(0);
expect(fixture.appendAudit).toHaveBeenCalledWith(expect.objectContaining({
action: 'bridge.chunk.expire',
errorCode: 'chunk_timeout',
}));
});
it('keeps the negotiated connection usable when resumable-session persistence fails', async () => {
const bridge = new EngineBridge();
fixture.setRuntimeSession.mockRejectedValueOnce(new Error('session storage unavailable'));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await connect(bridge);
expect(bridge.getStatus().state).toBe('connected');
expect(fixture.appendAudit).toHaveBeenCalledWith(expect.objectContaining({
action: 'bridge.session.persist',
errorCode: 'bridge_session_persist_failed',
}));
consoleError.mockRestore();
});
it('offers a persisted session only after completing the paired identity challenge', async () => {
fixture.runtimeSession = {
sessionId: 'previous-session',
engineInstanceId: 'previous-instance',
engineIdentityId: 'engine-identity-1',
updatedAt: NOW - 1_000,
};
const bridge = new EngineBridge();
const socket = await connect(bridge);
const auth = socket.sent.map((item) => JSON.parse(item)).find((item) => item.type === 'auth');
expect(auth).toMatchObject({
type: 'auth',
challenge: 'engine-challenge-0123456789',
resumeSessionId: 'previous-session',
});
});
it('rejects hello_ack before the paired identity challenge is answered', async () => {
const bridge = new EngineBridge();
const pending = bridge.connect(fixture.state.bridge);
const rejection = expect(pending).rejects.toThrow('身份挑战');
const socket = FakeWebSocket.instances.at(-1)!;
socket.open();
socket.receive(helloAck());
await rejection;
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
expect(bridge.getStatus()).toMatchObject({ state: 'error', message: expect.stringContaining('身份挑战') });
});
it('rejects handshake immediately when the transport closes before hello_ack', async () => {
const bridge = new EngineBridge();
const pending = bridge.connect(fixture.state.bridge);
const socket = FakeWebSocket.instances.at(-1)!;
socket.open();
socket.close(1006, 'fixture disconnect');
await expect(pending).rejects.toThrow('协商完成前断开');
});
it('terminates a silent handshake instead of leaving connect pending', async () => {
const bridge = new EngineBridge();
const pending = bridge.connect(fixture.state.bridge);
const rejection = expect(pending).rejects.toThrow('协议协商超过 5 秒');
const socket = FakeWebSocket.instances.at(-1)!;
socket.open();
await vi.advanceTimersByTimeAsync(5_001);
await rejection;
expect(socket.closeInfo).toEqual({ code: 1002, reason: 'handshake timeout' });
expect(bridge.getStatus()).toMatchObject({
state: 'error',
message: expect.stringContaining('协议协商超过 5 秒'),
});
});
it('uses the engine pairing deadline locally after pair_pending', async () => {
fixture.state.bridge = bridgeConfig(false);
const bridge = new EngineBridge();
const pending = bridge.startPairing();
await Promise.resolve();
await Promise.resolve();
const socket = FakeWebSocket.instances.at(-1)!;
socket.open();
socket.receive({
type: 'pair_pending',
protocolVersion: BRIDGE_PROTOCOL_VERSION,
requestId: 'pairing-1',
serverNonce: 'server-nonce-0123456789',
code: '123456',
expiresAt: NOW + 1_000,
engineIdentityId: 'engine-identity-1',
publicKey: enginePublicKey,
});
expect((await pending).state).toBe('pending');
await vi.advanceTimersByTimeAsync(1_001);
expect(bridge.getPairingStatus()).toMatchObject({ state: 'expired', requestId: 'pairing-1' });
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
});
it('rejects a pending startPairing call when the user cancels it', async () => {
fixture.state.bridge = bridgeConfig(false);
const bridge = new EngineBridge();
const pending = bridge.startPairing();
await Promise.resolve();
await Promise.resolve();
bridge.cancelPairing();
await expect(pending).rejects.toMatchObject({ code: 'cancelled' });
});
it('rejects pairing immediately when the initial request cannot be sent', async () => {
fixture.state.bridge = bridgeConfig(false);
const bridge = new EngineBridge();
const pending = bridge.startPairing();
await Promise.resolve();
await Promise.resolve();
const socket = FakeWebSocket.instances.at(-1)!;
socket.throwOnSend = true;
const rejection = expect(pending).rejects.toThrow('配对申请发送失败');
socket.open();
await rejection;
expect(bridge.getPairingStatus().state).toBe('error');
});
it('resolves an engine rejection without waiting for the local handshake timer', async () => {
fixture.state.bridge = bridgeConfig(false);
const bridge = new EngineBridge();
const pending = bridge.startPairing();
await Promise.resolve();
await Promise.resolve();
const socket = FakeWebSocket.instances.at(-1)!;
socket.open();
socket.receive({ type: 'pair_rejected', requestId: 'pairing-1', message: 'rejected by user' });
await expect(pending).resolves.toMatchObject({ state: 'rejected', message: 'rejected by user' });
});
it('does not claim pairing success when credentials cannot be persisted', async () => {
fixture.state.bridge = bridgeConfig(false);
const bridge = new EngineBridge();
const pending = bridge.startPairing();
await Promise.resolve();
await Promise.resolve();
const socket = FakeWebSocket.instances.at(-1)!;
socket.open();
socket.receive({
type: 'pair_pending',
protocolVersion: BRIDGE_PROTOCOL_VERSION,
requestId: 'pairing-1',
serverNonce: 'server-nonce-0123456789',
code: '123456',
expiresAt: NOW + 60_000,
engineIdentityId: 'engine-identity-1',
publicKey: enginePublicKey,
});
await expect(pending).resolves.toMatchObject({ state: 'pending' });
fixture.updateStateFailure = new Error('storage unavailable');
socket.receive({
type: 'pair_approved',
requestId: 'pairing-1',
deviceId: 'device-1',
engineIdentityId: 'engine-identity-1',
publicKey: enginePublicKey,
});
await vi.advanceTimersByTimeAsync(0);
expect(bridge.getPairingStatus()).toMatchObject({
state: 'error',
message: expect.stringContaining('无法保存'),
});
});
});
+300 -68
View File
@@ -1,7 +1,7 @@
import { browser } from 'wxt/browser';
import type { BridgeEnvelope } from '@/types/messages';
import type { BridgeConfig, BridgePairingStatus, BridgePublicKey, BridgeStatus } from '@/types/models';
import { BRIDGE_CAPABILITIES } from '@/protocol/capabilities';
import { BRIDGE_CAPABILITIES, getBridgeCapabilityCatalog } from '@/protocol/capabilities';
import {
BRIDGE_CHUNK_BYTES, BRIDGE_CHUNK_THRESHOLD_BYTES, BRIDGE_CHUNK_TIMEOUT_MS,
BRIDGE_MAX_CHUNK_TRANSFERS, BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_PROTOCOL_VERSION, parseBridgeEnvelope,
@@ -9,6 +9,7 @@ import {
} from '@/protocol/bridge';
import { getBridgeRuntimeSession, getState, setBridgeRuntimeSession, updateState } from '@/platform/storage/state';
import { routeCapability } from '@/features/grants/service';
import { currentActiveGrant } from '@/features/grants/lifecycle';
import { errorCode, ExtensionError, isDeniedErrorCode } from '@/shared/errors';
import { appendAuditEvent } from '@/features/diagnostics/audit';
import { beginAgentAction, finishAgentAction } from '@/features/agent-runtime/service';
@@ -22,6 +23,7 @@ const STATUS_EVENT = 'bridge.status.changed';
const PAIRING_STATUS_EVENT = 'bridge.pairing.status.changed';
const RECONNECT_DELAY = 3_000;
const HEARTBEAT_INTERVAL = 20_000;
export const BRIDGE_HEARTBEAT_TIMEOUT_MS = 45_000;
const HANDSHAKE_TIMEOUT = 5_000;
const MAX_CONCURRENT_REQUESTS = 8;
const ENGINE_REQUEST_TIMEOUT = 10_000;
@@ -38,6 +40,7 @@ interface ChunkAssembly {
total: number;
originalBytes: number;
parts: Array<Uint8Array | undefined>;
timer: ReturnType<typeof globalThis.setTimeout>;
}
function bytesToBase64(bytes: Uint8Array): string {
@@ -70,9 +73,11 @@ export class EngineBridge {
private nativePort?: Browser.runtime.Port;
private reconnectTimer?: ReturnType<typeof globalThis.setTimeout>;
private heartbeatTimer?: ReturnType<typeof globalThis.setInterval>;
private heartbeatWatchdog?: ReturnType<typeof globalThis.setTimeout>;
private handshakeTimer?: ReturnType<typeof globalThis.setTimeout>;
private handshakeResolve?: () => void;
private handshakeReject?: (error: Error) => void;
private handshakeChallengeAnswered = false;
private connectPromise?: Promise<void>;
private pairingSocket?: WebSocket;
private pairingTimer?: ReturnType<typeof globalThis.setTimeout>;
@@ -90,7 +95,9 @@ export class EngineBridge {
private readonly inFlight = new Map<string, AbortController>();
private readonly outgoing = new Map<string, OutgoingRequest>();
private readonly chunks = new Map<string, ChunkAssembly>();
private readonly pendingHeartbeats = new Map<number, number>();
private heartbeatSequence = 0;
private transportFailureMessage?: string;
private manuallyClosed = false;
private status: BridgeStatus = { state: 'disconnected', message: '未连接引擎' };
private pairingStatus: BridgePairingStatus = { state: 'idle', message: '尚未配对' };
@@ -100,11 +107,17 @@ export class EngineBridge {
}
getPairingStatus(): BridgePairingStatus {
if (this.pairingStatus.state === 'pending'
&& this.pairingStatus.expiresAt
&& this.pairingStatus.expiresAt <= Date.now()) {
this.expirePairing(this.pairingStatus.requestId);
}
return this.pairingStatus;
}
emitEvent(method: string, params: unknown): void {
if (this.status.state === 'connected') this.send({ type: 'event', method, params });
if (this.status.state !== 'connected') return;
this.trySend({ type: 'event', method, params });
}
requestEngine<T>(method: string, params: unknown, timeoutMs = ENGINE_REQUEST_TIMEOUT): Promise<T> {
@@ -122,7 +135,7 @@ export class EngineBridge {
return new Promise<T>((resolve, reject) => {
const timer = globalThis.setTimeout(() => {
this.outgoing.delete(id);
this.send({ type: 'cancel', id });
this.trySend({ type: 'cancel', id });
reject(new ExtensionError('engine_timeout', `Yak 引擎请求超过 ${timeoutMs}ms`));
}, timeoutMs);
this.outgoing.set(id, { resolve: (value) => resolve(value as T), reject, timer });
@@ -131,13 +144,21 @@ export class EngineBridge {
} catch (error) {
globalThis.clearTimeout(timer);
this.outgoing.delete(id);
reject(error instanceof Error ? error : new Error(String(error)));
const failure = error instanceof Error ? error : new Error(String(error));
this.failTransport(failure.message);
reject(failure);
}
});
}
async connect(config?: BridgeConfig): Promise<void> {
if (this.status.state === 'connected') return;
if (this.status.state === 'connected' && this.transportAvailable()) return;
if (this.status.state === 'connected') {
this.stopHeartbeat();
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', 'Bridge 传输已经失效'));
this.setStatus({ state: 'disconnected', message: 'Bridge 传输已经失效,正在重新连接' });
}
if (this.connectPromise) return this.connectPromise;
const effectiveConfig = config || (await getState()).bridge;
if (!effectiveConfig.pairedEngine) throw new Error('浏览器插件尚未与 Yak 引擎配对');
@@ -157,6 +178,7 @@ export class EngineBridge {
disconnect(): void {
this.manuallyClosed = true;
this.transportFailureMessage = undefined;
if (this.reconnectTimer) globalThis.clearTimeout(this.reconnectTimer);
this.failHandshake(new Error('Bridge 连接已取消'));
this.stopHeartbeat();
@@ -178,27 +200,42 @@ export class EngineBridge {
throw new Error('Bridge 仅允许连接本机 ws://127.0.0.1、localhost 或 ::1');
}
this.manuallyClosed = false;
this.transportFailureMessage = undefined;
this.setStatus({ state: 'connecting', message: '正在连接本地 Yak 引擎' });
const socket = new WebSocket(config.endpoint);
this.socket = socket;
const negotiated = this.createHandshakePromise();
socket.addEventListener('open', () => this.setStatus({ state: 'negotiating', message: '正在验证 Yak 引擎与浏览器身份' }));
socket.addEventListener('message', (event) => void this.onMessage(String(event.data)));
socket.addEventListener('message', (event) => {
if (this.socket === socket) void this.onMessage(String(event.data));
});
socket.addEventListener('error', () => {
if (this.socket !== socket) return;
const error = new Error('Bridge 连接失败');
if (this.status.state === 'connected') {
this.failTransport(error.message);
return;
}
this.failHandshake(error);
this.setStatus({ state: 'error', message: error.message });
try { socket.close(1011, 'websocket error'); } catch { /* The handshake is already terminal. */ }
});
socket.addEventListener('close', () => {
const ownsSocket = this.socket === socket;
if (!ownsSocket) return;
const transportFailure = this.transportFailureMessage
|| (this.status.state === 'error' ? this.status.message : undefined);
this.stopHeartbeat();
if (this.socket === socket) {
this.socket = undefined;
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', '与 Yak 引擎的连接已断开'));
}
this.socket = undefined;
this.transportFailureMessage = undefined;
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', '与 Yak 引擎的连接已断开'));
this.failHandshake(new Error('Bridge 在协议协商完成前断开'));
if (!this.manuallyClosed) this.setStatus({ state: 'disconnected', message: '与 Yak 引擎的连接已断开' });
if (!this.manuallyClosed) this.setStatus({
state: transportFailure ? 'error' : 'disconnected',
message: transportFailure || '与 Yak 引擎的连接已断开',
});
if (!this.manuallyClosed && config.autoConnect) this.scheduleReconnect(config);
});
return negotiated;
@@ -207,21 +244,30 @@ export class EngineBridge {
private async connectNative(config: BridgeConfig): Promise<void> {
if (!config.nativeHost.trim()) throw new Error('Native Messaging Host 名称不能为空');
this.manuallyClosed = false;
this.transportFailureMessage = undefined;
this.setStatus({ state: 'connecting', message: '正在连接 Yakit Native Host' });
const port = browser.runtime.connectNative(config.nativeHost.trim());
this.nativePort = port;
const negotiated = this.createHandshakePromise();
port.onMessage.addListener((message) => void this.onMessage(message));
port.onMessage.addListener((message) => {
if (this.nativePort === port) void this.onMessage(message);
});
port.onDisconnect.addListener(() => {
const lastError = browser.runtime.lastError?.message;
if (this.nativePort === port) {
this.nativePort = undefined;
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', 'Native Host 已断开'));
}
const ownsPort = this.nativePort === port;
if (!ownsPort) return;
const transportFailure = this.transportFailureMessage
|| (this.status.state === 'error' ? this.status.message : undefined);
this.nativePort = undefined;
this.transportFailureMessage = undefined;
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', 'Native Host 已断开'));
this.failHandshake(new Error(lastError || 'Native Host 在协议协商完成前断开'));
this.stopHeartbeat();
this.setStatus({ state: lastError ? 'error' : 'disconnected', message: lastError || 'Native Host 已断开' });
this.setStatus({
state: lastError || transportFailure ? 'error' : 'disconnected',
message: lastError || transportFailure || 'Native Host 已断开',
});
if (!this.manuallyClosed && config.autoConnect) this.scheduleReconnect(config);
});
this.setStatus({ state: 'negotiating', message: '正在验证 Yak 引擎与浏览器身份' });
@@ -246,12 +292,14 @@ export class EngineBridge {
if (!verified) throw new Error('Yak 引擎身份签名验证失败');
const [state, previousSession] = await Promise.all([getState(), getBridgeRuntimeSession()]);
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
const capabilityCatalog = await getBridgeCapabilityCatalog();
const auth: BridgeEnvelope = {
type: 'auth',
client: 'yakit-browser-extension',
version: browser.runtime.getManifest().version,
protocolVersion: BRIDGE_PROTOCOL_VERSION,
capabilities: [...BRIDGE_CAPABILITIES],
capabilityCatalog,
installationId: config.installationId,
taskId: state.activeGrant?.taskId,
grantId: state.activeGrant?.id,
@@ -266,10 +314,12 @@ export class EngineBridge {
envelope: auth,
}));
this.send(auth);
this.handshakeChallengeAnswered = true;
}
private createHandshakePromise(): Promise<void> {
this.failHandshake(new Error('Bridge 协议协商已被新连接替代'));
this.handshakeChallengeAnswered = false;
return new Promise<void>((resolve, reject) => {
this.handshakeResolve = resolve;
this.handshakeReject = reject;
@@ -285,6 +335,14 @@ export class EngineBridge {
private async completeHandshake(message: BridgeEnvelope): Promise<void> {
if (!this.handshakeResolve) return;
if (!this.handshakeChallengeAnswered) {
const error = new Error('Bridge 在完成身份挑战前返回了连接确认');
this.failHandshake(error);
this.setStatus({ state: 'error', message: error.message });
try { this.socket?.close(1008, 'authentication required'); } catch { /* The handshake is already terminal. */ }
try { this.nativePort?.disconnect(); } catch { /* The handshake is already terminal. */ }
return;
}
if (this.handshakeTimer) globalThis.clearTimeout(this.handshakeTimer);
const resolve = this.handshakeResolve;
this.handshakeTimer = undefined;
@@ -305,16 +363,26 @@ export class EngineBridge {
grantId: message.grantId,
resumed: message.resumed,
});
await setBridgeRuntimeSession({
this.heartbeatSequence = 0;
this.startHeartbeat();
resolve();
void setBridgeRuntimeSession({
sessionId: message.sessionId!,
engineInstanceId: message.engineInstanceId!,
engineIdentityId: message.engineIdentityId,
taskId: message.taskId,
grantId: message.grantId,
updatedAt: Date.now(),
}).catch((error) => {
console.error('Bridge resumable session persistence failed', error);
void appendAuditEvent({
category: 'bridge',
action: 'bridge.session.persist',
outcome: 'error',
errorCode: 'bridge_session_persist_failed',
summary: (error instanceof Error ? error.message : String(error)).slice(0, 512),
});
});
this.startHeartbeat();
resolve();
}
private failHandshake(error: Error): void {
@@ -323,6 +391,7 @@ export class EngineBridge {
this.handshakeTimer = undefined;
this.handshakeResolve = undefined;
this.handshakeReject = undefined;
this.handshakeChallengeAnswered = false;
reject?.(error);
}
@@ -376,12 +445,16 @@ export class EngineBridge {
if (!pending) return;
globalThis.clearTimeout(pending.timer);
this.outgoing.delete(message.id);
if (message.error) pending.reject(new ExtensionError(message.error.code, message.error.message));
if (message.error) pending.reject(new ExtensionError(
message.error.code,
message.error.message,
message.error.data,
));
else pending.resolve(message.result);
return;
}
if (message.type === 'ping') {
this.send({
this.trySend({
type: 'pong', id: message.id, sequence: message.sequence,
timestamp: message.timestamp, replyTimestamp: Date.now(),
});
@@ -389,8 +462,17 @@ export class EngineBridge {
}
if (message.type === 'pong') {
const now = Date.now();
const latencyMs = Math.max(0, now - Number(message.timestamp));
const sequence = Number(message.sequence);
const sentAt = this.pendingHeartbeats.get(sequence);
if (sentAt === undefined
|| message.id !== `heartbeat-${sequence}`
|| Number(message.timestamp) !== sentAt) return;
for (const sequence of this.pendingHeartbeats.keys()) {
if (sequence <= Number(message.sequence)) this.pendingHeartbeats.delete(sequence);
}
const latencyMs = Math.max(0, now - sentAt);
recordHeartbeat(latencyMs);
this.armHeartbeatWatchdog();
this.setStatus({
...this.status,
heartbeatSequence: message.sequence,
@@ -406,7 +488,7 @@ export class EngineBridge {
if (this.status.state !== 'connected' || message.type !== 'request' || !message.id || !message.method) return;
if (this.inFlight.size >= MAX_CONCURRENT_REQUESTS) {
this.send({
this.trySend({
type: 'response',
id: message.id,
error: { code: 'server_busy', message: `Bridge 并行请求已达到 ${MAX_CONCURRENT_REQUESTS} 个上限` },
@@ -420,7 +502,7 @@ export class EngineBridge {
return;
}
if (this.inFlight.has(message.id)) {
this.send({
this.trySend({
type: 'response', id: message.id,
error: { code: 'duplicate_request_id', message: 'Bridge 请求 ID 正在使用中' },
});
@@ -440,18 +522,24 @@ export class EngineBridge {
: undefined;
try {
const grant = (await getState()).activeGrant;
const grant = await currentActiveGrant();
taskId = grant?.taskId;
targetTabId ??= grant?.targets[0]?.tabId;
if (grant) {
const grantTarget = grant.targets.find((target) => target.tabId === targetTabId);
actionId = (await beginAgentAction(grant, {
requestId: message.id, method: message.method, targetTabId,
requestId: message.id,
method: message.method,
targetTabId,
isolationContextId: grantTarget?.isolationContextId,
})).id;
}
const operation = routeCapability(message.method, message.params, (method, params) => this.requestEngine(method, params));
const result = await Promise.race([operation, cancelled]);
const durationMs = performance.now() - startedAt;
this.send({ type: 'response', id: message.id, result });
if (!this.trySend({ type: 'response', id: message.id, result })) {
throw new ExtensionError('bridge_disconnected', '能力执行完成,但 Bridge 响应通道已经断开');
}
if (actionId) void finishAgentAction(actionId, 'success');
recordCapabilityMetric(message.method, durationMs, false);
void appendAuditEvent({
@@ -461,7 +549,7 @@ export class EngineBridge {
} catch (error) {
const code = errorCode(error);
recordCapabilityMetric(message.method, performance.now() - startedAt, true);
this.send({
this.trySend({
type: 'response',
id: message.id,
error: { code, message: error instanceof Error ? error.message : String(error) },
@@ -510,19 +598,37 @@ export class EngineBridge {
this.sendRaw(encoded);
}
private trySend(message: BridgeEnvelope): boolean {
try {
this.send(message);
return true;
} catch (error) {
this.failTransport(error instanceof Error ? error.message : String(error));
return false;
}
}
private sendRaw(encoded: string): void {
if (this.nativePort) {
this.nativePort.postMessage(JSON.parse(encoded) as BridgeEnvelope);
return;
try {
this.nativePort.postMessage(JSON.parse(encoded) as BridgeEnvelope);
return;
} catch (error) {
throw new ExtensionError('bridge_disconnected', `Native Host 发送失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (this.socket?.readyState !== WebSocket.OPEN) {
throw new ExtensionError('bridge_disconnected', 'Bridge WebSocket 已断开');
}
try {
this.socket.send(encoded);
} catch (error) {
throw new ExtensionError('bridge_disconnected', `Bridge WebSocket 发送失败: ${error instanceof Error ? error.message : String(error)}`);
}
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoded);
}
private acceptChunk(message: BridgeEnvelope): string | undefined {
const now = Date.now();
for (const [id, assembly] of this.chunks) {
if (now - assembly.createdAt > BRIDGE_CHUNK_TIMEOUT_MS) this.chunks.delete(id);
}
const transferId = message.transferId!;
let assembly = this.chunks.get(transferId);
if (!assembly) {
@@ -530,16 +636,17 @@ export class EngineBridge {
assembly = {
createdAt: now, total: message.total!, originalBytes: message.originalBytes!,
parts: new Array<Uint8Array | undefined>(message.total!),
timer: globalThis.setTimeout(() => this.expireChunk(transferId), BRIDGE_CHUNK_TIMEOUT_MS),
};
this.chunks.set(transferId, assembly);
}
if (assembly.total !== message.total || assembly.originalBytes !== message.originalBytes) {
this.chunks.delete(transferId);
this.deleteChunk(transferId);
throw new Error('Bridge 分片元数据不一致');
}
const part = base64ToBytes(message.data!);
if (part.byteLength > BRIDGE_CHUNK_BYTES || (message.index! < assembly.total - 1 && part.byteLength !== BRIDGE_CHUNK_BYTES)) {
this.chunks.delete(transferId);
this.deleteChunk(transferId);
throw new Error('Bridge 分片大小无效');
}
assembly.parts[message.index!] = part;
@@ -550,7 +657,7 @@ export class EngineBridge {
bytes.set(item!, offset);
offset += item!.byteLength;
}
this.chunks.delete(transferId);
this.deleteChunk(transferId);
if (offset !== assembly.originalBytes) throw new Error('Bridge 分片重组大小不匹配');
return new TextDecoder().decode(bytes);
}
@@ -560,10 +667,55 @@ export class EngineBridge {
this.reconnectTimer = globalThis.setTimeout(() => void this.connect(config).catch(() => undefined), RECONNECT_DELAY);
}
private transportAvailable(): boolean {
return Boolean(this.nativePort || this.socket?.readyState === WebSocket.OPEN);
}
private failTransport(message: string): void {
if (this.manuallyClosed) return;
const normalized = message.trim() || 'Bridge 传输不可用';
this.transportFailureMessage = normalized;
this.stopHeartbeat();
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', normalized));
this.setStatus({ state: 'error', message: normalized });
try {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
this.socket.close(1011, 'bridge transport failure');
}
} catch { /* The status and pending calls have already failed closed. */ }
try { this.nativePort?.disconnect(); } catch { /* The status is already terminal. */ }
}
private deleteChunk(transferId: string): void {
const assembly = this.chunks.get(transferId);
if (!assembly) return;
globalThis.clearTimeout(assembly.timer);
this.chunks.delete(transferId);
}
private expireChunk(transferId: string): void {
const assembly = this.chunks.get(transferId);
if (!assembly) return;
this.deleteChunk(transferId);
void appendAuditEvent({
category: 'bridge',
action: 'bridge.chunk.expire',
outcome: 'error',
errorCode: 'chunk_timeout',
durationMs: Date.now() - assembly.createdAt,
summary: `${assembly.parts.filter(Boolean).length}/${assembly.total} 个分片`,
});
}
private clearChunks(): void {
for (const transferId of [...this.chunks.keys()]) this.deleteChunk(transferId);
}
private abortInFlight(): void {
for (const controller of this.inFlight.values()) controller.abort();
this.inFlight.clear();
this.chunks.clear();
this.clearChunks();
}
private rejectOutgoing(error: Error): void {
@@ -577,16 +729,38 @@ export class EngineBridge {
private startHeartbeat(): void {
this.stopHeartbeat();
const ping = () => {
if (this.status.state !== 'connected') return;
const sequence = ++this.heartbeatSequence;
this.send({ type: 'ping', id: `heartbeat-${sequence}`, sequence, timestamp: Date.now() });
const sentAt = Date.now();
this.pendingHeartbeats.set(sequence, sentAt);
while (this.pendingHeartbeats.size > 4) {
const oldest = this.pendingHeartbeats.keys().next().value;
if (typeof oldest !== 'number') break;
this.pendingHeartbeats.delete(oldest);
}
if (!this.trySend({ type: 'ping', id: `heartbeat-${sequence}`, sequence, timestamp: sentAt })) {
this.pendingHeartbeats.delete(sequence);
}
};
this.armHeartbeatWatchdog();
ping();
this.heartbeatTimer = globalThis.setInterval(ping, HEARTBEAT_INTERVAL);
}
private armHeartbeatWatchdog(): void {
if (this.heartbeatWatchdog) globalThis.clearTimeout(this.heartbeatWatchdog);
this.heartbeatWatchdog = globalThis.setTimeout(() => {
this.heartbeatWatchdog = undefined;
this.failTransport(`Yak 引擎心跳超过 ${BRIDGE_HEARTBEAT_TIMEOUT_MS / 1_000} 秒未响应`);
}, BRIDGE_HEARTBEAT_TIMEOUT_MS);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) globalThis.clearInterval(this.heartbeatTimer);
if (this.heartbeatWatchdog) globalThis.clearTimeout(this.heartbeatWatchdog);
this.heartbeatTimer = undefined;
this.heartbeatWatchdog = undefined;
this.pendingHeartbeats.clear();
}
private setStatus(status: BridgeStatus): void {
@@ -601,7 +775,8 @@ export class EngineBridge {
const config = (await getState()).bridge;
if (config.pairedEngine) return { state: 'approved', message: '当前浏览器已经完成配对', engineIdentityId: config.pairedEngine.engineIdentityId };
if (!isLoopbackEndpoint(config.endpoint)) throw new Error('配对仅允许访问本机 Yak Bridge');
if (this.pairingSocket && ['requesting', 'pending'].includes(this.pairingStatus.state)) return this.pairingStatus;
const currentPairing = this.getPairingStatus();
if (this.pairingSocket && ['requesting', 'pending'].includes(currentPairing.state)) return currentPairing;
this.cancelPairing(false);
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
const clientNonce = randomBridgeNonce();
@@ -619,21 +794,33 @@ export class EngineBridge {
this.pairingTimer = globalThis.setTimeout(() => {
const error = new Error('Yak 引擎配对请求超过 5 秒未响应');
this.failPairing(error);
socket.close(1000, 'pairing timeout');
}, HANDSHAKE_TIMEOUT);
});
socket.addEventListener('open', () => {
socket.send(JSON.stringify({
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
installationId: config.installationId,
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
nonce: clientNonce, publicKey: identity.publicKey,
} satisfies BridgePairingEnvelope));
if (this.pairingSocket !== socket) return;
try {
socket.send(JSON.stringify({
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
installationId: config.installationId,
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
nonce: clientNonce, publicKey: identity.publicKey,
} satisfies BridgePairingEnvelope));
} catch (error) {
this.failPairing(new Error(`Yak 配对申请发送失败: ${error instanceof Error ? error.message : String(error)}`));
}
});
socket.addEventListener('message', (event) => {
if (this.pairingSocket !== socket) return;
void this.onPairingMessage(String(event.data)).catch((error) => {
this.failPairing(error instanceof Error ? error : new Error(String(error)));
});
});
socket.addEventListener('error', () => {
if (this.pairingSocket === socket) this.failPairing(new Error('无法连接本机 Yak 配对服务'));
});
socket.addEventListener('message', (event) => void this.onPairingMessage(String(event.data)));
socket.addEventListener('error', () => this.failPairing(new Error('无法连接本机 Yak 配对服务')));
socket.addEventListener('close', () => {
if (this.pairingSocket === socket) this.pairingSocket = undefined;
if (this.pairingSocket !== socket) return;
this.pairingSocket = undefined;
if (['requesting', 'pending'].includes(this.pairingStatus.state)) this.failPairing(new Error('Yak 配对连接已断开'));
});
return pending;
@@ -641,6 +828,7 @@ export class EngineBridge {
cancelPairing(notify = true): BridgePairingStatus {
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
const reject = this.pairingReject;
this.pairingTimer = undefined;
this.pairingResolve = undefined;
this.pairingReject = undefined;
@@ -649,6 +837,7 @@ export class EngineBridge {
this.pairingSocket = undefined;
const status: BridgePairingStatus = { state: 'idle', message: '配对已取消' };
if (notify) this.setPairingStatus(status);
reject?.(new ExtensionError('cancelled', 'Yak 引擎配对已取消'));
return status;
}
@@ -686,18 +875,20 @@ export class EngineBridge {
});
if (code !== message.code) {
this.failPairing(new Error('Yak 配对验证码校验失败'));
this.pairingSocket?.close(1008, 'pairing transcript mismatch');
return;
}
context.requestId = message.requestId;
context.engineIdentityId = message.engineIdentityId;
context.enginePublicKey = message.publicKey;
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
this.pairingTimer = undefined;
const status: BridgePairingStatus = {
state: 'pending', message: '请在 Yakit 中确认相同的验证码',
requestId: message.requestId, code, engineIdentityId: message.engineIdentityId, expiresAt: message.expiresAt,
};
this.pairingTimer = globalThis.setTimeout(
() => this.expirePairing(message.requestId),
Math.max(0, Number(message.expiresAt) - Date.now()),
);
this.setPairingStatus(status);
this.pairingResolve?.(status);
this.pairingResolve = undefined;
@@ -705,35 +896,73 @@ export class EngineBridge {
return;
}
if (message.type === 'pair_approved') {
if (this.pairingStatus.expiresAt && this.pairingStatus.expiresAt <= Date.now()) {
this.expirePairing(this.pairingStatus.requestId);
return;
}
if (!context.requestId || message.requestId !== context.requestId || !context.engineIdentityId || !context.enginePublicKey
|| message.engineIdentityId !== context.engineIdentityId || !message.publicKey || !publicKeysEqual(message.publicKey, context.enginePublicKey)) {
this.failPairing(new Error('Yak 配对批准信息与当前申请不一致'));
return;
}
const next = await updateState((current) => ({
...current,
bridge: {
...current.bridge,
autoConnect: true,
pairedEngine: {
engineIdentityId: message.engineIdentityId!, deviceId: message.deviceId!,
publicKey: message.publicKey!, pairedAt: Date.now(),
let next: Awaited<ReturnType<typeof updateState>>;
try {
next = await updateState((current) => ({
...current,
bridge: {
...current.bridge,
autoConnect: true,
pairedEngine: {
engineIdentityId: message.engineIdentityId!, deviceId: message.deviceId!,
publicKey: message.publicKey!, pairedAt: Date.now(),
},
},
},
}));
}));
} catch (error) {
this.failPairing(new Error(`无法保存 Yak 配对凭据: ${error instanceof Error ? error.message : String(error)}`));
return;
}
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
this.pairingTimer = undefined;
this.setPairingStatus({ state: 'approved', message: '已与 Yak 引擎安全配对', engineIdentityId: message.engineIdentityId });
this.pairingContext = undefined;
this.pairingSocket?.close(1000, 'pairing approved');
const socket = this.pairingSocket;
this.pairingSocket = undefined;
await this.connect(next.bridge);
socket?.close(1000, 'pairing approved');
void this.connect(next.bridge).catch(() => undefined);
return;
}
const state = message.type === 'pair_rejected' ? 'rejected' : message.type === 'pair_expired' ? 'expired' : 'error';
const status: BridgePairingStatus = { state, message: message.message || 'Yak 引擎拒绝了配对申请', requestId: message.requestId };
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
this.pairingTimer = undefined;
this.setPairingStatus(status);
this.pairingResolve?.(status);
this.pairingResolve = undefined;
this.pairingReject = undefined;
this.pairingContext = undefined;
this.pairingSocket?.close(1000, state);
const socket = this.pairingSocket;
this.pairingSocket = undefined;
socket?.close(1000, state);
}
private expirePairing(requestId?: string): void {
if (this.pairingStatus.state !== 'pending') return;
if (requestId && this.pairingStatus.requestId !== requestId) return;
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
this.pairingTimer = undefined;
const status: BridgePairingStatus = {
state: 'expired',
message: 'Yak 引擎配对申请已经过期,请重新发起',
requestId: this.pairingStatus.requestId,
engineIdentityId: this.pairingStatus.engineIdentityId,
expiresAt: this.pairingStatus.expiresAt,
};
this.pairingContext = undefined;
this.setPairingStatus(status);
const socket = this.pairingSocket;
this.pairingSocket = undefined;
socket?.close(1000, 'pairing expired');
}
private failPairing(error: Error): void {
@@ -744,6 +973,9 @@ export class EngineBridge {
this.pairingReject = undefined;
this.pairingContext = undefined;
this.setPairingStatus({ state: 'error', message: error.message });
const socket = this.pairingSocket;
this.pairingSocket = undefined;
try { socket?.close(1000, 'pairing failed'); } catch { /* Status is already terminal. */ }
}
private setPairingStatus(status: BridgePairingStatus): void {