From 21dcee3a2451630bb4428e43779f37bfaa4afd3e Mon Sep 17 00:00:00 2001 From: Philipp Winterle Date: Thu, 17 Sep 2026 15:48:41 +0200 Subject: [PATCH] feat: Let a UI channel declare itself the preferred bridge channel The primary channel - the one that answers every MCP and CTL operation, and whose account decides isPaidUser() - was whichever UI connected first. With the desktop app and a browser tab both open, which one that is comes down to startup order, there's no way to steer it, and nothing reports which one won. The operations message now carries an optional priority. The highest one becomes primary, and connection order still breaks ties, so a UI that sends no priority behaves exactly as before. Anything that isn't a finite number is read as no preference, so a malformed message can't take the role away from a well-behaved UI. Handing the role over mid-session now emits operations-changed for the new primary, and not-ready if it has no operations yet - previously only a disconnect could change primary, so neither case could arise. --- src/api/ui-operation-bridge.ts | 52 +++++++--- test/integration/ui-operation-bridge.spec.ts | 99 +++++++++++++++++++- 2 files changed, 136 insertions(+), 15 deletions(-) diff --git a/src/api/ui-operation-bridge.ts b/src/api/ui-operation-bridge.ts index d2754cae9..eb2dca500 100644 --- a/src/api/ui-operation-bridge.ts +++ b/src/api/ui-operation-bridge.ts @@ -29,9 +29,14 @@ interface BridgeChannel { operations: HtkOperation[]; user?: User; authenticated: boolean; + // Declared by the UI in its operations message. Higher wins the primary role, + // so a UI that knows it's the one being driven (the desktop app) is preferred + // over one that just happens to be open too (a browser tab). + priority: number; // Operations received before authentication completes are buffered here // and applied atomically once auth succeeds. pendingOperations?: HtkOperation[]; + pendingPriority?: unknown; // Monotonic sequence number for in-flight JWT validations. Only the // latest validation is applied; earlier ones are discarded if superseded. authSeq: number; @@ -77,7 +82,13 @@ export class UiOperationBridge extends EventEmitter { } private get primaryChannel(): BridgeChannel | undefined { - return this.channels[0]; + // Highest declared priority wins, and connection order breaks ties, so + // channels that declare nothing keep behaving exactly as they always have. + return this.channels.reduce((best, channel) => + !best || channel.priority > best.priority + ? channel + : best + , undefined); } get isReady(): boolean { @@ -106,6 +117,7 @@ export class UiOperationBridge extends EventEmitter { ws, operations: [], authenticated: false, + priority: 0, authSeq: 0 }; this.channels.push(channel); @@ -336,13 +348,14 @@ export class UiOperationBridge extends EventEmitter { // processed until both the token and JWT are fully validated. if (data.type === 'operations') { channel.pendingOperations = data.operations ?? []; + channel.pendingPriority = data.priority; } return; } switch (data.type) { case 'operations': - this.applyOperations(channel, data.operations ?? []); + this.applyOperations(channel, data.operations ?? [], data.priority); break; case 'response': @@ -351,17 +364,32 @@ export class UiOperationBridge extends EventEmitter { } } - private applyOperations(channel: BridgeChannel, operations: HtkOperation[]): void { + private applyOperations( + channel: BridgeChannel, + operations: HtkOperation[], + priority: unknown + ): void { const wasReady = this.isReady; - channel.operations = operations; + const previousPrimary = this.primaryChannel; - // Only emit events when the primary channel's operations change - if (channel === this.primaryChannel) { - if (!wasReady && this.isReady) { - this.emit('ready'); - } - this.emit('operations-changed', this.currentOperations); + channel.operations = operations; + // Anything that isn't a real number means 'no preference', so a malformed + // message can never take the primary role away from a well-behaved UI: + channel.priority = typeof priority === 'number' && Number.isFinite(priority) + ? priority + : 0; + + // Only emit events when the primary channel's operations change, or when + // this message handed the role to a different channel: + const primaryChanged = this.primaryChannel !== previousPrimary; + if (channel !== this.primaryChannel && !primaryChanged) return; + + if (!wasReady && this.isReady) { + this.emit('ready'); + } else if (wasReady && !this.isReady) { + this.emit('not-ready'); } + this.emit('operations-changed', this.currentOperations); } private handleAuth(channel: BridgeChannel, data: { token?: string; jwt: string | false }): void { @@ -428,8 +456,10 @@ export class UiOperationBridge extends EventEmitter { // This guarantees operations are never exposed until auth succeeds. if (channel.pendingOperations !== undefined) { const ops = channel.pendingOperations; + const priority = channel.pendingPriority; channel.pendingOperations = undefined; - this.applyOperations(channel, ops); + channel.pendingPriority = undefined; + this.applyOperations(channel, ops, priority); } } diff --git a/test/integration/ui-operation-bridge.spec.ts b/test/integration/ui-operation-bridge.spec.ts index 1b79fd18d..220ae7a4e 100644 --- a/test/integration/ui-operation-bridge.spec.ts +++ b/test/integration/ui-operation-bridge.spec.ts @@ -129,7 +129,8 @@ function waitForMessage(ws: WebSocket, type: string): Promise { async function connectMockUi( bridge: UiOperationBridge, ops: HtkOperation[] = TEST_OPERATIONS, - authToken?: string + authToken?: string, + priority?: number ): Promise<{ clientWs: WebSocket; wss: WebSocketServer }> { const pair = await createWsPair(bridge); const wasReady = bridge.isReady; @@ -145,7 +146,8 @@ async function connectMockUi( pair.clientWs.send(JSON.stringify({ type: 'operations', - operations: ops + operations: ops, + priority })); if (!wasReady) { @@ -183,8 +185,12 @@ describe("UiOperationBridge", () => { done(); }); - async function setupMockUi(ops: HtkOperation[] = TEST_OPERATIONS, authToken?: string) { - const pair = await connectMockUi(bridge, ops, authToken); + async function setupMockUi( + ops: HtkOperation[] = TEST_OPERATIONS, + authToken?: string, + priority?: number + ) { + const pair = await connectMockUi(bridge, ops, authToken, priority); activePairs.push(pair); return pair; } @@ -857,4 +863,89 @@ describe("UiOperationBridge", () => { expect(bridge.isReady).to.be.true; }); }); + + describe("Channel priority", () => { + + function answerRequests(pair: { clientWs: WebSocket }, result: any) { + pair.clientWs.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type !== 'request') return; + pair.clientWs.send(JSON.stringify({ type: 'response', id: msg.id, result })); + }); + } + + const OTHER_OPERATIONS: HtkOperation[] = [{ + name: 'other.op', + description: 'Different operation', + category: 'other', + tiers: ['free', 'pro'], + inputSchema: { type: 'object', properties: {} } + }]; + + it("should prefer a higher-priority channel over an earlier one", async () => { + await setupMockUi(); + await setupMockUi(OTHER_OPERATIONS, undefined, 1); + + const result = await makeApiRequest('GET', '/api/operations'); + expect(result).to.have.length(1); + expect(result[0].name).to.equal('other.op'); + }); + + it("should prefer a higher-priority channel that connected first", async () => { + await setupMockUi(OTHER_OPERATIONS, undefined, 1); + await setupMockUi(); + + const result = await makeApiRequest('GET', '/api/operations'); + expect(result).to.have.length(1); + expect(result[0].name).to.equal('other.op'); + }); + + it("should keep connection order between equal priorities", async () => { + await setupMockUi(TEST_OPERATIONS, undefined, 1); + await setupMockUi(OTHER_OPERATIONS, undefined, 1); + + const result = await makeApiRequest('GET', '/api/operations'); + expect(result).to.have.length(2); + expect(result[0].name).to.equal('proxy.get-config'); + }); + + it("should route execute requests to the higher-priority channel", async () => { + const first = await setupMockUi(); + const preferred = await setupMockUi(TEST_OPERATIONS, undefined, 1); + + answerRequests(first, { from: 'first' }); + answerRequests(preferred, { from: 'preferred' }); + + const result = await makeApiRequest('POST', '/api/execute', { + name: 'proxy.get-config', + args: {} + }); + expect(result).to.deep.equal({ from: 'preferred' }); + }); + + it("should fall back to a lower-priority channel when the preferred one goes", async () => { + const fallback = await setupMockUi(); + const preferred = await setupMockUi(TEST_OPERATIONS, undefined, 1); + + answerRequests(fallback, { from: 'fallback' }); + + preferred.clientWs.close(); + await new Promise(resolve => bridge.once('operations-changed', () => resolve())); + + expect(bridge.isReady).to.be.true; + const result = await makeApiRequest('POST', '/api/execute', { + name: 'proxy.get-config', + args: {} + }); + expect(result).to.deep.equal({ from: 'fallback' }); + }); + + it("should ignore a priority that is not a finite number", async () => { + await setupMockUi(); + await setupMockUi(OTHER_OPERATIONS, undefined, 'highest' as any); + + const result = await makeApiRequest('GET', '/api/operations'); + expect(result[0].name).to.equal('proxy.get-config'); + }); + }); });