From e25ad6d8c5483ef8ff3cf166841fd34265cd7f87 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Wed, 24 Jun 2026 16:53:31 +0800 Subject: [PATCH] Fix remote control revoke token handling --- src/components/Dispatch/index.tsx | 87 ++++++++++++++++++++++------- src/lib/remoteControl.ts | 32 +++++++++++ src/store/triggerStore.ts | 6 +- test/unit/lib/remoteControl.test.ts | 64 +++++++++++++++++++++ 4 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 test/unit/lib/remoteControl.test.ts diff --git a/src/components/Dispatch/index.tsx b/src/components/Dispatch/index.tsx index 61a3c3f8..731d0b92 100644 --- a/src/components/Dispatch/index.tsx +++ b/src/components/Dispatch/index.tsx @@ -21,6 +21,8 @@ import { Button } from '@/components/ui/button'; import { createRemoteControlSession, getRemoteControlDesktopInstanceId, + isRemoteControlAlreadyGoneError, + parseRemoteControlLinkToken, revokeRemoteControlSession, waitForRemoteControlBridgeConnected, } from '@/lib/remoteControl'; @@ -339,9 +341,6 @@ export function WorkspaceDispatch() { const removeRemoteControlSession = useTriggerStore( (s) => s.removeRemoteControlSession ); - const clearRemoteControlSessions = useTriggerStore( - (s) => s.clearRemoteControlSessions - ); const addRemoteControlLog = useTriggerStore((s) => s.addRemoteControlLog); const [remoteControlLoading, setRemoteControlLoading] = useState(false); @@ -445,6 +444,7 @@ export function WorkspaceDispatch() { url: res.url, title, expiresAt: res.expires_at, + linkToken: parseRemoteControlLinkToken(res.url), }); addRemoteControlLog({ name: title, status: 'created' }); @@ -491,13 +491,29 @@ export function WorkspaceDispatch() { .activeRemoteControlSessions.find((s) => s.sessionId === sessionId); setStoppingSessionId(sessionId); try { - await revokeRemoteControlSession(sessionId); - } catch { - /* best-effort */ - } finally { + const linkToken = + session?.linkToken || + (session ? parseRemoteControlLinkToken(session.url) : ''); + if (!linkToken) { + throw new Error('Remote control link token is missing.'); + } + await revokeRemoteControlSession(sessionId, linkToken); removeRemoteControlSession(sessionId); if (session) addRemoteControlLog({ name: session.title, status: 'stopped' }); + toast.success('Remote control link revoked'); + } catch (err: any) { + // The link is already revoked or expired server-side; the goal is + // achieved, so clean up locally instead of stranding the entry. + if (isRemoteControlAlreadyGoneError(err)) { + removeRemoteControlSession(sessionId); + if (session) + addRemoteControlLog({ name: session.title, status: 'stopped' }); + toast.success('Remote control link revoked'); + } else { + toast.error(err?.message || 'Failed to revoke remote control link.'); + } + } finally { setStoppingSessionId(null); } }, @@ -505,21 +521,54 @@ export function WorkspaceDispatch() { ); const handleStopAllRemoteControl = useCallback(async () => { - const toStop = activeSessions.map((s) => ({ - id: s.sessionId, - title: s.title, - })); + const toStop = activeSessions; if (toStop.length === 0) return; - setStoppingSessionId(toStop[0].id); - await Promise.allSettled( - toStop.map(({ id }) => revokeRemoteControlSession(id)) + setStoppingSessionId(toStop[0].sessionId); + const results = await Promise.allSettled( + toStop.map(async (session) => { + const linkToken = + session.linkToken || parseRemoteControlLinkToken(session.url); + if (!linkToken) { + throw new Error('Remote control link token is missing.'); + } + try { + await revokeRemoteControlSession(session.sessionId, linkToken); + } catch (err: any) { + // Already revoked or expired server-side; treat it as stopped so the + // entry is cleaned up locally. + if (!isRemoteControlAlreadyGoneError(err)) { + throw err; + } + } + return session; + }) ); - toStop.forEach(({ title }) => - addRemoteControlLog({ name: title, status: 'stopped' }) - ); - clearRemoteControlSessions(); + let stoppedCount = 0; + results.forEach((result) => { + if (result.status !== 'fulfilled') { + return; + } + stoppedCount += 1; + removeRemoteControlSession(result.value.sessionId); + addRemoteControlLog({ name: result.value.title, status: 'stopped' }); + }); + const failedCount = results.length - stoppedCount; + if (stoppedCount > 0) { + toast.success( + stoppedCount === 1 + ? 'Remote control link revoked' + : `${stoppedCount} remote control links revoked` + ); + } + if (failedCount > 0) { + toast.error( + failedCount === 1 + ? 'Failed to revoke 1 remote control link.' + : `Failed to revoke ${failedCount} remote control links.` + ); + } setStoppingSessionId(null); - }, [activeSessions, clearRemoteControlSessions, addRemoteControlLog]); + }, [activeSessions, removeRemoteControlSession, addRemoteControlLog]); const handleCopySession = useCallback( (url: string) => { diff --git a/src/lib/remoteControl.ts b/src/lib/remoteControl.ts index 3d7510fa..4987590d 100644 --- a/src/lib/remoteControl.ts +++ b/src/lib/remoteControl.ts @@ -116,6 +116,38 @@ function normalizeRemoteControlUrl(url: string): string { return url; } +export function parseRemoteControlLinkToken(url: string): string { + try { + const parsed = new URL(url, 'http://localhost'); + const fragmentToken = new URLSearchParams( + parsed.hash.replace(/^#/, '') + ).get('t'); + if (fragmentToken) { + return fragmentToken; + } + return parsed.searchParams.get('t') || ''; + } catch { + const [, hash = ''] = url.split('#', 2); + const fragmentToken = new URLSearchParams(hash).get('t'); + if (fragmentToken) { + return fragmentToken; + } + const [, query = ''] = url.split('?', 2); + return new URLSearchParams(query.split('#', 1)[0]).get('t') || ''; + } +} + +/** + * The revoke endpoint is not idempotent: once a session is revoked or expired + * server-side, a subsequent DELETE returns 410 (Gone), and a missing session + * returns 404. In both cases the link is already dead, so callers should treat + * the revoke as successful and clean up local state instead of stranding it. + */ +export function isRemoteControlAlreadyGoneError(err: unknown): boolean { + const status = (err as { status?: number } | null)?.status; + return status === 404 || status === 410; +} + function remoteLinkHeaders(linkToken?: string | null): Record { if (!linkToken) { return {}; diff --git a/src/store/triggerStore.ts b/src/store/triggerStore.ts index 3a58abba..c7158f16 100644 --- a/src/store/triggerStore.ts +++ b/src/store/triggerStore.ts @@ -45,6 +45,7 @@ export interface RemoteControlSession { url: string; title: string; expiresAt?: string; + linkToken?: string; } export type RemoteControlLogStatus = 'created' | 'stopped' | 'expired'; @@ -81,7 +82,6 @@ interface TriggerStore { clearWebSocketEvent: () => void; addRemoteControlSession: (session: RemoteControlSession) => void; removeRemoteControlSession: (sessionId: string) => void; - clearRemoteControlSessions: () => void; addRemoteControlLog: ( entry: Omit ) => void; @@ -189,10 +189,6 @@ export const useTriggerStore = create((set, get) => ({ })); }, - clearRemoteControlSessions: () => { - set({ activeRemoteControlSessions: [] }); - }, - addRemoteControlLog: (entry) => { const newEntry: RemoteControlLogEntry = { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, diff --git a/test/unit/lib/remoteControl.test.ts b/test/unit/lib/remoteControl.test.ts new file mode 100644 index 00000000..b9bddafe --- /dev/null +++ b/test/unit/lib/remoteControl.test.ts @@ -0,0 +1,64 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { describe, expect, it } from 'vitest'; + +import { + isRemoteControlAlreadyGoneError, + parseRemoteControlLinkToken, +} from '@/lib/remoteControl'; + +describe('parseRemoteControlLinkToken', () => { + it('reads the canonical fragment token', () => { + expect( + parseRemoteControlLinkToken( + 'https://remote.eigent.ai/remote-control/rcs_test#t=fragment-token' + ) + ).toBe('fragment-token'); + }); + + it('falls back to query token for legacy links', () => { + expect( + parseRemoteControlLinkToken('/remote-control/rcs_test?t=query-token') + ).toBe('query-token'); + }); + + it('returns empty string when the token is absent', () => { + expect(parseRemoteControlLinkToken('/remote-control/rcs_test')).toBe(''); + }); +}); + +describe('isRemoteControlAlreadyGoneError', () => { + it('treats 410 (already revoked/expired) as already gone', () => { + expect(isRemoteControlAlreadyGoneError({ status: 410 })).toBe(true); + }); + + it('treats 404 (session not found) as already gone', () => { + expect(isRemoteControlAlreadyGoneError({ status: 404 })).toBe(true); + }); + + it('does not treat other errors as already gone', () => { + expect(isRemoteControlAlreadyGoneError({ status: 403 })).toBe(false); + expect(isRemoteControlAlreadyGoneError({ status: 400 })).toBe(false); + expect(isRemoteControlAlreadyGoneError({ status: 500 })).toBe(false); + }); + + it('handles errors without a status (e.g. network failures)', () => { + expect(isRemoteControlAlreadyGoneError(new Error('Network error'))).toBe( + false + ); + expect(isRemoteControlAlreadyGoneError(null)).toBe(false); + expect(isRemoteControlAlreadyGoneError(undefined)).toBe(false); + }); +});