Fix remote control revoke token handling

This commit is contained in:
4pmtong 2026-06-24 16:53:31 +08:00
parent a9d109477a
commit e25ad6d8c5
4 changed files with 165 additions and 24 deletions

View file

@ -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) => {

View file

@ -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<string, string> {
if (!linkToken) {
return {};

View file

@ -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<RemoteControlLogEntry, 'id' | 'time'>
) => void;
@ -189,10 +189,6 @@ export const useTriggerStore = create<TriggerStore>((set, get) => ({
}));
},
clearRemoteControlSessions: () => {
set({ activeRemoteControlSessions: [] });
},
addRemoteControlLog: (entry) => {
const newEntry: RemoteControlLogEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,

View file

@ -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);
});
});