From 8b5b5a8e00554f2ab8090978d05eeafcd637c024 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 18 May 2026 03:41:54 -0400 Subject: [PATCH] Add revocable runtime share access (#2236) Co-authored-by: Orca --- src/main/ipc/mobile.test.ts | 64 ++++ src/main/ipc/mobile.ts | 35 +++ src/main/runtime/runtime-rpc.test.ts | 91 ++++++ src/main/runtime/runtime-rpc.ts | 9 + src/preload/api-types.ts | 3 + src/preload/index.ts | 5 + .../settings/RuntimeAccessGrantList.tsx | 118 ++++++++ .../settings/RuntimeEnvironmentsPane.tsx | 18 +- .../settings/RuntimePairingUrlGenerator.tsx | 273 +++++++++++++----- src/renderer/src/web/web-preload-api.ts | 2 + src/shared/runtime-access-grants.ts | 6 + 11 files changed, 542 insertions(+), 82 deletions(-) create mode 100644 src/renderer/src/components/settings/RuntimeAccessGrantList.tsx create mode 100644 src/shared/runtime-access-grants.ts diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index dc78e888f51..82c3677c8db 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -132,4 +132,68 @@ describe('registerMobileHandlers', () => { scope: 'runtime' }) }) + + it('lists runtime access grants including unused generated links', () => { + const rpcServer = { + getDeviceRegistry: () => ({ + listDevices: () => [ + { + deviceId: 'mobile-1', + name: 'Phone', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + }, + { + deviceId: 'runtime-1', + name: 'Browser', + scope: 'runtime', + pairedAt: 3, + lastSeenAt: 4 + }, + { + deviceId: 'pending-runtime', + name: 'Copied link', + scope: 'runtime', + pairedAt: 5, + lastSeenAt: 0 + } + ] + }) + } + + registerMobileHandlers(rpcServer as never) + + expect(handlers.get('mobile:listRuntimeAccessGrants')?.()).toEqual({ + grants: [ + { + deviceId: 'pending-runtime', + name: 'Copied link', + createdAt: 5, + lastSeenAt: null + }, + { + deviceId: 'runtime-1', + name: 'Browser', + createdAt: 3, + lastSeenAt: 4 + } + ] + }) + }) + + it('revokes runtime access through the runtime server', () => { + const revokeRuntimeAccess = vi.fn().mockReturnValue(true) + const rpcServer = { + getDeviceRegistry: () => ({}), + revokeRuntimeAccess + } + + registerMobileHandlers(rpcServer as never) + + expect(handlers.get('mobile:revokeRuntimeAccess')?.(null, { deviceId: 'runtime-1' })).toEqual({ + revoked: true + }) + expect(revokeRuntimeAccess).toHaveBeenCalledWith('runtime-1') + }) }) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index fc0315846eb..258e3f85ccd 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -1,6 +1,8 @@ import { ipcMain } from 'electron' import { networkInterfaces } from 'os' import QRCode from 'qrcode' +import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' +import type { DeviceEntry } from '../runtime/device-registry' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' export type NetworkInterface = { @@ -33,6 +35,15 @@ function getLanAddress(): string | null { return ifaces.length > 0 ? ifaces[0]!.address : null } +function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant { + return { + deviceId: device.deviceId, + name: device.name, + createdAt: device.pairedAt, + lastSeenAt: device.lastSeenAt > 0 ? device.lastSeenAt : null + } +} + // Why: the mobile IPC handlers provide the renderer with QR code pairing data, // device management, and WebSocket readiness status. They depend on the // OrcaRuntimeRpcServer because it owns the device registry and TLS state. @@ -137,6 +148,22 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { } }) + ipcMain.handle('mobile:listRuntimeAccessGrants', () => { + const registry = rpcServer.getDeviceRegistry() + if (!registry) { + return { grants: [] } + } + // Why: generated web/runtime links are bearer credentials even before a + // client first connects, so pending runtime grants must stay revocable. + return { + grants: registry + .listDevices() + .filter((d) => d.scope === 'runtime') + .sort((a, b) => b.pairedAt - a.pairedAt) + .map(toRuntimeAccessGrant) + } + }) + ipcMain.handle('mobile:revokeDevice', (_event, args: { deviceId: string }) => { const registry = rpcServer.getDeviceRegistry() if (!registry) { @@ -145,6 +172,14 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { return { revoked: rpcServer.revokeMobileDevice(args.deviceId) } }) + ipcMain.handle('mobile:revokeRuntimeAccess', (_event, args: { deviceId: string }) => { + const registry = rpcServer.getDeviceRegistry() + if (!registry) { + return { revoked: false } + } + return { revoked: rpcServer.revokeRuntimeAccess(args.deviceId) } + }) + ipcMain.handle('mobile:isWebSocketReady', () => { return { ready: rpcServer.getWebSocketEndpoint() !== null, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 55c5fcba04a..2ca478d6709 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -519,6 +519,97 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('terminates active WebSockets for a revoked runtime access grant', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + try { + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'runtime-test', + scope: 'runtime' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + const first = await authenticateMobileWs(offer.pairingUrl) + const second = await authenticateMobileWs(offer.pairingUrl) + + expect(server.revokeRuntimeAccess(offer.deviceId)).toBe(true) + await Promise.all([waitForWsClose(first), waitForWsClose(second)]) + await waitFor(() => server['e2eeChannels'].size === 0 && server['wsConnectionIds'].size === 0) + + expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)).toBeNull() + } finally { + await server.stop() + } + }) + + it('rotates unused runtime pairing links without revoking already-used grants', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + try { + const first = server.createPairingOffer({ + address: '127.0.0.1', + name: 'runtime-test', + rotate: true, + scope: 'runtime' + }) + const second = server.createPairingOffer({ + address: '127.0.0.1', + name: 'runtime-test', + rotate: true, + scope: 'runtime' + }) + expect(first.available).toBe(true) + expect(second.available).toBe(true) + if (!first.available || !second.available) { + throw new Error('WebSocket pairing unavailable') + } + + expect(first.deviceId).not.toBe(second.deviceId) + expect(parsePairingCode(first.pairingUrl)?.deviceToken).not.toBe( + parsePairingCode(second.pairingUrl)?.deviceToken + ) + expect(server.getDeviceRegistry()?.getDevice(first.deviceId)).toBeNull() + + server.getDeviceRegistry()?.updateLastSeen(second.deviceId) + const third = server.createPairingOffer({ + address: '127.0.0.1', + name: 'runtime-test', + rotate: true, + scope: 'runtime' + }) + expect(third.available).toBe(true) + if (!third.available) { + throw new Error('WebSocket pairing unavailable') + } + + expect(server.getDeviceRegistry()?.getDevice(second.deviceId)).not.toBeNull() + expect(server.getDeviceRegistry()?.getDevice(third.deviceId)).not.toBeNull() + } finally { + await server.stop() + } + }) + it('caps WebSocket long-polls and aborts them when the socket closes', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index beb9bbf6e31..950f36a837c 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -298,6 +298,15 @@ export class OrcaRuntimeRpcServer { return true } + revokeRuntimeAccess(deviceId: string): boolean { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.wsTransport?.terminateClientConnections(device.token) + return true + } + getWebSocketEndpoint(): string | null { const ws = this.transports.find((t) => t.kind === 'websocket') return ws?.endpoint ?? null diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c18b145f11c..e41fa6a00d2 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -101,6 +101,7 @@ import type { } from '../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' +import type { RuntimeAccessGrant } from '../shared/runtime-access-grants' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' import type { AddIssueCommentBySlugArgs, @@ -1893,6 +1894,8 @@ export type PreloadApi = { devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] }> revokeDevice: (args: { deviceId: string }) => Promise<{ revoked: boolean }> + listRuntimeAccessGrants: () => Promise<{ grants: RuntimeAccessGrant[] }> + revokeRuntimeAccess: (args: { deviceId: string }) => Promise<{ revoked: boolean }> isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }> } speech: { diff --git a/src/preload/index.ts b/src/preload/index.ts index e3e033fec2b..a85e9670e0e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2958,6 +2958,11 @@ const api = { revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> => ipcRenderer.invoke('mobile:revokeDevice', args), + listRuntimeAccessGrants: () => ipcRenderer.invoke('mobile:listRuntimeAccessGrants'), + + revokeRuntimeAccess: (args: { deviceId: string }): Promise<{ revoked: boolean }> => + ipcRenderer.invoke('mobile:revokeRuntimeAccess', args), + isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => ipcRenderer.invoke('mobile:isWebSocketReady') }, diff --git a/src/renderer/src/components/settings/RuntimeAccessGrantList.tsx b/src/renderer/src/components/settings/RuntimeAccessGrantList.tsx new file mode 100644 index 00000000000..35a9d18f9a4 --- /dev/null +++ b/src/renderer/src/components/settings/RuntimeAccessGrantList.tsx @@ -0,0 +1,118 @@ +import { Loader2, RefreshCw, Trash2 } from 'lucide-react' +import type { RuntimeAccessGrant } from '../../../../shared/runtime-access-grants' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' + +function formatAccessTimestamp(timestamp: number): string { + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }).format(new Date(timestamp)) +} + +type RuntimeAccessGrantListProps = { + className?: string + grants: RuntimeAccessGrant[] + currentGrantId: string | null + isLoading: boolean + revokingGrantId: string | null + onRefresh: () => void + onRevoke: (grant: RuntimeAccessGrant) => void +} + +export function RuntimeAccessGrantList({ + className, + grants, + currentGrantId, + isLoading, + revokingGrantId, + onRefresh, + onRevoke +}: RuntimeAccessGrantListProps): React.JSX.Element { + return ( +
+
+

Shared Server Access

+ + + + + + Refresh shared access + + +
+ + {grants.length === 0 ? ( +

No shared server access yet.

+ ) : ( +
+ {grants.map((grant) => { + const isCurrent = currentGrantId === grant.deviceId + const isRevoking = revokingGrantId === grant.deviceId + return ( +
+
+
+ {grant.name} + {isCurrent ? ( + Current link + ) : null} +
+
+ Created {formatAccessTimestamp(grant.createdAt)} ยท{' '} + {grant.lastSeenAt + ? `Last used ${formatAccessTimestamp(grant.lastSeenAt)}` + : 'Not used yet'} +
+
+ + + + + + Revoke access + + +
+ ) + })} +
+ )} + {grants.length > 0 ? ( +

+ Anyone with an active grant can connect until you revoke it. Revoking shared access + disconnects active clients immediately. +

+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index bad28dccbf1..332725e48c0 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -387,9 +387,9 @@ export function RuntimeEnvironmentsPane({
-
Share this desktop
+
Share this Orca server

- Generate a pairing URL for the web client or another Orca client. + Create a revocable access grant so a browser or another Orca client can connect.

- {shareServerFormOpen ? ( -
- -
- ) : null} +
+ +
) : null} diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index 5b96398a938..f10ee3bef15 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -1,10 +1,12 @@ import { Check, Copy, Loader2, RefreshCw } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' +import type { RuntimeAccessGrant } from '../../../../shared/runtime-access-grants' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { RuntimeAccessGrantList } from './RuntimeAccessGrantList' const LOOPBACK_ADDRESS = '127.0.0.1' @@ -15,20 +17,24 @@ const runtimePairingUrlCache: { customAddress: string runtimePairingUrl: string | null webClientUrl: string | null + runtimePairingDeviceId: string | null } = { selectedAddress: LOOPBACK_ADDRESS, customAddress: '', runtimePairingUrl: null, - webClientUrl: null + webClientUrl: null, + runtimePairingDeviceId: null } function GeneratedUrlRow({ label, + description, value, copied, onCopy }: { label: string + description?: string value: string copied: boolean onCopy: () => void @@ -36,6 +42,7 @@ function GeneratedUrlRow({ return (
+ {description ?

{description}

: null}
{value} @@ -54,14 +61,33 @@ function GeneratedUrlRow({ ) } +function UnavailableUrlRow({ + label, + description +}: { + label: string + description: string +}): React.JSX.Element { + return ( +
+ +
+

{description}

+
+
+ ) +} + type RuntimePairingUrlGeneratorProps = { framed?: boolean showHeader?: boolean + showGeneratorForm?: boolean } export function RuntimePairingUrlGenerator({ framed = true, - showHeader = true + showHeader = true, + showGeneratorForm = true }: RuntimePairingUrlGeneratorProps): React.JSX.Element { const [networkInterfaces, setNetworkInterfaces] = useState<{ name: string; address: string }[]>( [] @@ -74,8 +100,40 @@ export function RuntimePairingUrlGenerator({ const [webClientUrl, setWebClientUrl] = useState( runtimePairingUrlCache.webClientUrl ) + const [runtimePairingDeviceId, setRuntimePairingDeviceId] = useState( + runtimePairingUrlCache.runtimePairingDeviceId + ) + const [runtimeAccessGrants, setRuntimeAccessGrants] = useState([]) + const [isLoadingAccessGrants, setIsLoadingAccessGrants] = useState(false) + const [revokingGrantId, setRevokingGrantId] = useState(null) const [copiedTarget, setCopiedTarget] = useState<'web' | 'pairing' | null>(null) const [isGeneratingPairing, setIsGeneratingPairing] = useState(false) + const accessGrantLoadIdRef = useRef(0) + + const loadRuntimeAccessGrants = useCallback( + async (options: { showToastOnError?: boolean } = {}): Promise => { + const loadId = accessGrantLoadIdRef.current + 1 + accessGrantLoadIdRef.current = loadId + setIsLoadingAccessGrants(true) + try { + const result = await window.api.mobile.listRuntimeAccessGrants() + if (loadId === accessGrantLoadIdRef.current) { + setRuntimeAccessGrants(result.grants) + } + } catch (error) { + if (loadId === accessGrantLoadIdRef.current && options.showToastOnError) { + toast.error( + error instanceof Error ? error.message : 'Failed to load shared access grants.' + ) + } + } finally { + if (loadId === accessGrantLoadIdRef.current) { + setIsLoadingAccessGrants(false) + } + } + }, + [] + ) useEffect(() => { let stale = false @@ -98,6 +156,22 @@ export function RuntimePairingUrlGenerator({ } }, []) + useEffect(() => { + void loadRuntimeAccessGrants() + return () => { + accessGrantLoadIdRef.current += 1 + } + }, [loadRuntimeAccessGrants]) + + const clearGeneratedUrls = (): void => { + runtimePairingUrlCache.runtimePairingUrl = null + runtimePairingUrlCache.webClientUrl = null + runtimePairingUrlCache.runtimePairingDeviceId = null + setRuntimePairingUrl(null) + setWebClientUrl(null) + setRuntimePairingDeviceId(null) + } + const generateRuntimePairingUrl = async (): Promise => { setIsGeneratingPairing(true) try { @@ -107,17 +181,17 @@ export function RuntimePairingUrlGenerator({ rotate: true }) if (!result.available) { - runtimePairingUrlCache.runtimePairingUrl = null - runtimePairingUrlCache.webClientUrl = null - setRuntimePairingUrl(null) - setWebClientUrl(null) + clearGeneratedUrls() toast.error('Runtime pairing is unavailable.') return } runtimePairingUrlCache.runtimePairingUrl = result.pairingUrl runtimePairingUrlCache.webClientUrl = result.webClientUrl + runtimePairingUrlCache.runtimePairingDeviceId = result.deviceId setRuntimePairingUrl(result.pairingUrl) setWebClientUrl(result.webClientUrl) + setRuntimePairingDeviceId(result.deviceId) + await loadRuntimeAccessGrants() toast.success(result.webClientUrl ? 'Generated web client URL.' : 'Generated pairing URL.') } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to generate pairing URL.') @@ -126,6 +200,29 @@ export function RuntimePairingUrlGenerator({ } } + const revokeRuntimeAccess = async (grant: RuntimeAccessGrant): Promise => { + setRevokingGrantId(grant.deviceId) + try { + const result = await window.api.mobile.revokeRuntimeAccess({ deviceId: grant.deviceId }) + if (!result.revoked) { + toast.error('Shared access was already revoked.') + await loadRuntimeAccessGrants() + return + } + setRuntimeAccessGrants((current) => + current.filter((entry) => entry.deviceId !== grant.deviceId) + ) + if (runtimePairingDeviceId === grant.deviceId) { + clearGeneratedUrls() + } + toast.success('Shared access revoked.') + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to revoke shared access.') + } finally { + setRevokingGrantId(null) + } + } + const copyGeneratedUrl = async (target: 'web' | 'pairing', value: string): Promise => { try { await window.api.ui.writeClipboardText(value) @@ -142,6 +239,7 @@ export function RuntimePairingUrlGenerator({ const containerClassName = framed ? 'space-y-3 rounded-lg border border-border/50 bg-muted/25 p-3' : 'space-y-4' + const sharedAccessClassName = showGeneratorForm ? 'border-t border-border/40 pt-3' : '' const updateSelectedAddress = (address: string): void => { runtimePairingUrlCache.selectedAddress = address @@ -157,83 +255,110 @@ export function RuntimePairingUrlGenerator({
{showHeader ? (
- +

- Generate a runtime pairing URL for the web client or another Orca client. + Create a revocable access grant for browser or desktop clients.

) : null} -
-
-
- - + - {networkInterface.name} ({networkInterface.address}) - - ))} - - + + + + + This computer ({LOOPBACK_ADDRESS}) + + {networkInterfaces.map((networkInterface, index) => ( + + {networkInterface.name} ({networkInterface.address}) + + ))} + + +
+
+ + updateCustomAddress(event.target.value)} + placeholder="host, host:port, or wss://host/path" + className="h-8 font-mono text-xs" + /> +
+
+

+ 127.0.0.1 only works on this computer. Use a LAN, Tailscale, or custom address for + another device. +

+
+ +
-
- - updateCustomAddress(event.target.value)} - placeholder="host, host:port, or wss://host/path" - className="h-8 font-mono text-xs" + + {webClientUrl ? ( + void copyGeneratedUrl('web', webClientUrl)} /> -
-
-
- -
-
+ ) : runtimePairingUrl ? ( + + ) : null} - {webClientUrl ? ( - void copyGeneratedUrl('web', webClientUrl)} - /> + {runtimePairingUrl ? ( + void copyGeneratedUrl('pairing', runtimePairingUrl)} + /> + ) : null} + ) : null} - {runtimePairingUrl ? ( - void copyGeneratedUrl('pairing', runtimePairingUrl)} - /> - ) : null} + void loadRuntimeAccessGrants({ showToastOnError: true })} + onRevoke={(grant) => void revokeRuntimeAccess(grant)} + />
) } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 7079616e119..8f88f7bf08b 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -201,6 +201,8 @@ function createWebPreloadApi(): Partial { getRuntimePairingUrl: () => Promise.resolve({ available: false }), listDevices: () => Promise.resolve({ devices: [] }), revokeDevice: () => Promise.resolve({ revoked: false }), + listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }), + revokeRuntimeAccess: () => Promise.resolve({ revoked: false }), isWebSocketReady: () => Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null }) }, telemetryTrack: () => Promise.resolve(), diff --git a/src/shared/runtime-access-grants.ts b/src/shared/runtime-access-grants.ts new file mode 100644 index 00000000000..522c6e808e2 --- /dev/null +++ b/src/shared/runtime-access-grants.ts @@ -0,0 +1,6 @@ +export type RuntimeAccessGrant = { + deviceId: string + name: string + createdAt: number + lastSeenAt: number | null +}