mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 12:41:53 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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('无法保存'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user