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 ( +
No shared server access yet.
+ ) : ( ++ Anyone with an active grant can connect until you revoke it. Revoking shared access + disconnects active clients immediately. +
+ ) : null} +- 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.
{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}
-
-
-
-
-
-
-
-
-
+ ) : 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
+}