mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-07-09 17:28:35 +00:00
Fix remote control revoke token handling
This commit is contained in:
parent
a9d109477a
commit
e25ad6d8c5
4 changed files with 165 additions and 24 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
|
|
|
|||
64
test/unit/lib/remoteControl.test.ts
Normal file
64
test/unit/lib/remoteControl.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue