feat(mobile): add Expo mobile app with QR pairing and WebSocket RPC transport

Adds the Orca Mobile companion app (Expo SDK 55 / React Native 0.83) with:
- QR code pairing flow: scan orca://pair# URL, validate auth, save host profile
- WebSocket RPC client with auto-reconnect, exponential backoff, streaming support
- Three screens: host list, worktree list, terminal session with live output
- Mock WebSocket server for development without a running Orca desktop instance
- Desktop-side: TLS certificate management, device registry, mobile IPC handlers,
  WebSocket transport layer, and pairing offer encode/decode with Zod validation
- Unit tests for WebSocket transport and pairing codec

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-27 20:02:21 -04:00
co-authored by Orca
parent ba0f263df1
commit fd5d4374c3
47 changed files with 15458 additions and 172 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules/
.expo/
dist/
android/
ios/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
+3
View File
@@ -0,0 +1,3 @@
{
"rules": {}
}
+65
View File
@@ -0,0 +1,65 @@
# Orca Mobile
React Native companion app for Orca. Monitor worktrees, view terminal output, and send commands from your phone.
## Prerequisites
- Node.js 24+
- pnpm
- Expo Go app on your phone (from App Store / Google Play), **or** a dev client build
## Quick Start
```bash
cd mobile
pnpm install
pnpm start # starts Expo dev server
```
Scan the QR code shown in the terminal with your phone's camera (iOS) or the Expo Go app (Android).
## Development Paths
### I have an Android phone
1. Install Expo Go from Google Play
2. Run `pnpm start`, scan QR with Expo Go
3. For native modules (camera): `npx expo prebuild --platform android && cd android && ./gradlew assembleDebug`
4. Install APK: `adb install android/app/build/outputs/apk/debug/app-debug.apk`
5. Run with `pnpm start --dev-client`
### I only have a Mac (iOS Simulator)
1. Install Xcode from the App Store
2. Run `pnpm start --ios` to open in iOS Simulator
## Mock Server
Develop the mobile app without a running Orca desktop instance:
```bash
pnpm mock-server # starts mock WebSocket server on port 6768
```
Connect from the app using endpoint `ws://localhost:6768` and token `mock-device-token`.
## Connecting to Real Orca
1. Start Orca desktop with WebSocket transport enabled
2. In Orca, go to Settings > Mobile and scan the QR code with this app
3. The QR encodes the connection endpoint, device token, and TLS fingerprint
## Project Structure
```
mobile/
├── app/ # Expo Router screens (file-based routing)
│ ├── _layout.tsx # Root layout with navigation stack
│ ├── index.tsx # Home screen — paired hosts list
│ └── pair-scan.tsx # QR code scanning screen
├── src/
│ └── transport/ # WebSocket RPC client (TBD)
├── scripts/
│ └── mock-server.ts # Standalone mock WebSocket server
└── assets/ # App icons and splash screen
```
+32
View File
@@ -0,0 +1,32 @@
{
"expo": {
"name": "Orca Mobile",
"slug": "orca-mobile",
"version": "0.0.1",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"scheme": "orca",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.stably.orca.mobile"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#1a1a2e"
},
"package": "com.stably.orca.mobile"
},
"plugins": [
"expo-router",
[
"expo-camera",
{
"cameraPermission": "Allow Orca to use the camera for QR code scanning."
}
]
]
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
export default function RootLayout() {
return (
<>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#1a1a2e' },
headerTintColor: '#e0e0e0',
contentStyle: { backgroundColor: '#0d0d1a' }
}}
/>
</>
)
}
+216
View File
@@ -0,0 +1,216 @@
import { useState, useEffect, useCallback } from 'react'
import { View, Text, StyleSheet, FlatList, Pressable, ActivityIndicator } from 'react-native'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { connect, type RpcClient } from '../../../src/transport/rpc-client'
import { loadHosts, updateLastConnected } from '../../../src/transport/host-store'
import type { ConnectionState, RpcSuccess } from '../../../src/transport/types'
type Worktree = {
worktreeId: string
repo: string
branch: string
liveTerminalCount: number
hasAttachedPty: boolean
preview: string
unread: boolean
}
export default function HostScreen() {
const { hostId } = useLocalSearchParams<{ hostId: string }>()
const router = useRouter()
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
const [worktrees, setWorktrees] = useState<Worktree[]>([])
const [error, setError] = useState('')
useEffect(() => {
let rpcClient: RpcClient | null = null
void (async () => {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (!host) {
setError('Host not found')
return
}
rpcClient = connect(host.endpoint, host.deviceToken, setConnState)
setClient(rpcClient)
await updateLastConnected(host.id)
})()
return () => {
rpcClient?.close()
}
}, [hostId])
const fetchWorktrees = useCallback(async () => {
if (!client || connState !== 'connected') return
try {
const response = await client.sendRequest('worktree.ps')
if (response.ok) {
const result = (response as RpcSuccess).result as { worktrees: Worktree[] }
setWorktrees(result.worktrees)
}
} catch {
// Will retry on reconnect
}
}, [client, connState])
useEffect(() => {
if (connState === 'connected') {
void fetchWorktrees()
}
}, [connState, fetchWorktrees])
if (error) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.statusBar}>
<View style={[styles.statusDot, connState === 'connected' && styles.statusConnected]} />
<Text style={styles.statusText}>{connState}</Text>
</View>
{connState === 'connecting' && (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#3b82f6" />
<Text style={styles.connectingText}>Connecting...</Text>
</View>
)}
{connState === 'connected' && worktrees.length === 0 && (
<View style={styles.centered}>
<Text style={styles.emptyText}>No active worktrees</Text>
</View>
)}
{worktrees.length > 0 && (
<FlatList
data={worktrees}
keyExtractor={(w) => w.worktreeId}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<Pressable
style={styles.worktreeCard}
onPress={() =>
router.push(`/h/${hostId}/session/${encodeURIComponent(item.worktreeId)}`)
}
>
<View style={styles.worktreeHeader}>
<Text style={styles.worktreeRepo}>{item.repo}</Text>
{item.unread && <View style={styles.unreadDot} />}
</View>
<Text style={styles.worktreeBranch}>{item.branch}</Text>
{item.preview ? (
<Text style={styles.worktreePreview} numberOfLines={2}>
{item.preview}
</Text>
) : null}
<Text style={styles.worktreeMeta}>
{item.liveTerminalCount} terminal{item.liveTerminalCount !== 1 ? 's' : ''}
</Text>
</Pressable>
)}
/>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0d0d1a'
},
statusBar: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
paddingHorizontal: 16
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#666',
marginRight: 8
},
statusConnected: {
backgroundColor: '#22c55e'
},
statusText: {
color: '#888',
fontSize: 13
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
connectingText: {
color: '#e0e0e0',
fontSize: 16,
marginTop: 16
},
emptyText: {
color: '#888',
fontSize: 16
},
errorText: {
color: '#ef4444',
fontSize: 16
},
list: {
padding: 16,
paddingTop: 0
},
worktreeCard: {
backgroundColor: '#1a1a2e',
borderRadius: 12,
padding: 16,
marginBottom: 12
},
worktreeHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4
},
worktreeRepo: {
fontSize: 18,
fontWeight: '600',
color: '#e0e0e0'
},
unreadDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#3b82f6',
marginLeft: 8
},
worktreeBranch: {
fontSize: 14,
color: '#3b82f6',
fontFamily: 'monospace',
marginBottom: 8
},
worktreePreview: {
fontSize: 13,
color: '#888',
fontFamily: 'monospace',
marginBottom: 8,
lineHeight: 18
},
worktreeMeta: {
fontSize: 12,
color: '#666'
}
})
@@ -0,0 +1,284 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform
} from 'react-native'
import { useLocalSearchParams } from 'expo-router'
import { connect, type RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
import type { ConnectionState, RpcSuccess } from '../../../../src/transport/types'
type Terminal = {
handle: string
title: string
isActive: boolean
}
export default function SessionScreen() {
const { hostId, worktreeId } = useLocalSearchParams<{ hostId: string; worktreeId: string }>()
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
const [terminals, setTerminals] = useState<Terminal[]>([])
const [output, setOutput] = useState('')
const [input, setInput] = useState('')
const [activeHandle, setActiveHandle] = useState<string | null>(null)
const scrollRef = useRef<ScrollView>(null)
const unsubRef = useRef<(() => void) | null>(null)
useEffect(() => {
let rpcClient: RpcClient | null = null
void (async () => {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (!host) return
rpcClient = connect(host.endpoint, host.deviceToken, setConnState)
setClient(rpcClient)
})()
return () => {
unsubRef.current?.()
rpcClient?.close()
}
}, [hostId])
const fetchTerminals = useCallback(async () => {
if (!client) return
try {
const response = await client.sendRequest('terminal.list', {
worktree: `id:${worktreeId}`
})
if (response.ok) {
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
setTerminals(result.terminals)
const active = result.terminals.find((t) => t.isActive) ?? result.terminals[0]
if (active && active.handle !== activeHandle) {
setActiveHandle(active.handle)
subscribeToTerminal(active.handle)
}
}
} catch {
// Will retry on reconnect
}
}, [client, worktreeId, activeHandle])
useEffect(() => {
if (connState === 'connected') {
void fetchTerminals()
}
}, [connState, fetchTerminals])
function subscribeToTerminal(handle: string) {
unsubRef.current?.()
if (!client) return
setOutput('')
const unsub = client.subscribe('terminal.subscribe', { terminal: handle }, (result) => {
const data = result as Record<string, unknown>
if (data.type === 'scrollback') {
setOutput(data.lines as string)
scrollToBottom()
} else if (data.type === 'data') {
setOutput((prev) => prev + (data.chunk as string))
scrollToBottom()
}
})
unsubRef.current = unsub
}
function scrollToBottom() {
setTimeout(() => {
scrollRef.current?.scrollToEnd({ animated: true })
}, 50)
}
async function handleSend() {
if (!client || !activeHandle || !input.trim()) return
const text = input
setInput('')
try {
await client.sendRequest('terminal.send', {
terminal: activeHandle,
text,
enter: true
})
} catch {
// Failed to send
}
}
const activeTerminal = terminals.find((t) => t.handle === activeHandle)
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={90}
>
<View style={styles.header}>
<View style={[styles.statusDot, connState === 'connected' && styles.statusConnected]} />
<Text style={styles.headerTitle} numberOfLines={1}>
{activeTerminal?.title ?? 'Terminal'}
</Text>
</View>
{terminals.length > 1 && (
<ScrollView horizontal style={styles.tabBar} showsHorizontalScrollIndicator={false}>
{terminals.map((t) => (
<Pressable
key={t.handle}
style={[styles.tab, t.handle === activeHandle && styles.tabActive]}
onPress={() => {
setActiveHandle(t.handle)
subscribeToTerminal(t.handle)
}}
>
<Text
style={[styles.tabText, t.handle === activeHandle && styles.tabTextActive]}
numberOfLines={1}
>
{t.title || 'Terminal'}
</Text>
</Pressable>
))}
</ScrollView>
)}
<ScrollView
ref={scrollRef}
style={styles.outputScroll}
contentContainerStyle={styles.outputContent}
>
<Text style={styles.outputText} selectable>
{output || 'Waiting for output...'}
</Text>
</ScrollView>
<View style={styles.inputBar}>
<TextInput
style={styles.textInput}
value={input}
onChangeText={setInput}
placeholder="Type a command..."
placeholderTextColor="#555"
autoCapitalize="none"
autoCorrect={false}
returnKeyType="send"
onSubmitEditing={() => void handleSend()}
/>
<Pressable style={styles.sendButton} onPress={() => void handleSend()}>
<Text style={styles.sendButtonText}>Send</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0d0d1a'
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#1a1a2e'
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#666',
marginRight: 8
},
statusConnected: {
backgroundColor: '#22c55e'
},
headerTitle: {
color: '#e0e0e0',
fontSize: 16,
fontWeight: '600',
flex: 1
},
tabBar: {
maxHeight: 44,
borderBottomWidth: 1,
borderBottomColor: '#1a1a2e'
},
tab: {
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 2,
borderBottomColor: 'transparent'
},
tabActive: {
borderBottomColor: '#3b82f6'
},
tabText: {
color: '#888',
fontSize: 13
},
tabTextActive: {
color: '#e0e0e0'
},
outputScroll: {
flex: 1
},
outputContent: {
padding: 12
},
outputText: {
color: '#d4d4d4',
fontFamily: 'monospace',
fontSize: 13,
lineHeight: 18
},
inputBar: {
flexDirection: 'row',
alignItems: 'center',
padding: 8,
paddingHorizontal: 12,
borderTopWidth: 1,
borderTopColor: '#1a1a2e',
backgroundColor: '#111127'
},
textInput: {
flex: 1,
backgroundColor: '#1a1a2e',
color: '#e0e0e0',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
fontFamily: 'monospace',
marginRight: 8
},
sendButton: {
backgroundColor: '#3b82f6',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8
},
sendButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '600'
}
})
+121
View File
@@ -0,0 +1,121 @@
import { useState, useCallback } from 'react'
import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native'
import { useRouter } from 'expo-router'
import { useFocusEffect } from 'expo-router'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
export default function HomeScreen() {
const router = useRouter()
const [hosts, setHosts] = useState<HostProfile[]>([])
useFocusEffect(
useCallback(() => {
void loadHosts().then(setHosts)
}, [])
)
return (
<View style={styles.container}>
{hosts.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.title}>Orca Mobile</Text>
<Text style={styles.subtitle}>No hosts paired yet</Text>
<Pressable style={styles.button} onPress={() => router.push('/pair-scan')}>
<Text style={styles.buttonText}>+ Add Host</Text>
</Pressable>
</View>
) : (
<>
<FlatList
data={hosts}
keyExtractor={(h) => h.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<Pressable style={styles.hostCard} onPress={() => router.push(`/h/${item.id}`)}>
<Text style={styles.hostName}>{item.name}</Text>
<Text style={styles.hostEndpoint}>{item.endpoint}</Text>
{item.lastConnected > 0 && (
<Text style={styles.hostMeta}>
Last connected {new Date(item.lastConnected).toLocaleDateString()}
</Text>
)}
</Pressable>
)}
/>
<Pressable style={styles.addButton} onPress={() => router.push('/pair-scan')}>
<Text style={styles.buttonText}>+ Add Host</Text>
</Pressable>
</>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0d0d1a'
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24
},
title: {
fontSize: 28,
fontWeight: '700',
color: '#e0e0e0',
marginBottom: 8
},
subtitle: {
fontSize: 16,
color: '#888',
marginBottom: 32
},
list: {
padding: 16
},
hostCard: {
backgroundColor: '#1a1a2e',
borderRadius: 12,
padding: 16,
marginBottom: 12
},
hostName: {
fontSize: 18,
fontWeight: '600',
color: '#e0e0e0',
marginBottom: 4
},
hostEndpoint: {
fontSize: 13,
color: '#888',
fontFamily: 'monospace'
},
hostMeta: {
fontSize: 12,
color: '#666',
marginTop: 8
},
button: {
backgroundColor: '#3b82f6',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8
},
addButton: {
backgroundColor: '#3b82f6',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
margin: 16,
alignItems: 'center'
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600'
}
})
+191
View File
@@ -0,0 +1,191 @@
import { useState, useRef, useCallback } from 'react'
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
import { CameraView, useCameraPermissions } from 'expo-camera'
import { useRouter } from 'expo-router'
import { decodePairingUrl } from '../src/transport/pairing'
import { connect } from '../src/transport/rpc-client'
import { saveHost } from '../src/transport/host-store'
import type { PairingOffer, RpcSuccess } from '../src/transport/types'
export default function PairScanScreen() {
const router = useRouter()
const [permission, requestPermission] = useCameraPermissions()
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
const [errorMessage, setErrorMessage] = useState('')
const processingRef = useRef(false)
const handleBarCodeScanned = useCallback(
({ data }: { data: string }) => {
if (processingRef.current) return
processingRef.current = true
const offer = decodePairingUrl(data)
if (!offer) {
setStatus('error')
setErrorMessage('Not a valid Orca QR code')
processingRef.current = false
return
}
void testAndSave(offer)
},
[router]
)
async function testAndSave(offer: PairingOffer) {
setStatus('connecting')
const client = connect(offer.endpoint, offer.deviceToken)
try {
const response = await client.sendRequest('status.get')
client.close()
if (!response.ok) {
if (response.error.code === 'unauthorized') {
setStatus('error')
setErrorMessage('Authentication failed — token may be expired')
processingRef.current = false
return
}
setStatus('error')
setErrorMessage(`Server error: ${response.error.message}`)
processingRef.current = false
return
}
const runtimeId = (response as RpcSuccess)._meta.runtimeId
const hostId = `host-${Date.now()}`
await saveHost({
id: hostId,
name: runtimeId,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
certFingerprint: offer.certFingerprint,
lastConnected: Date.now()
})
router.replace(`/h/${hostId}`)
} catch {
setStatus('error')
setErrorMessage('Cannot connect — check that your computer is on the same network')
processingRef.current = false
}
}
function retry() {
setStatus('scanning')
setErrorMessage('')
processingRef.current = false
}
if (!permission) {
return (
<View style={styles.container}>
<ActivityIndicator color="#3b82f6" />
</View>
)
}
if (!permission.granted) {
return (
<View style={styles.container}>
<Text style={styles.title}>Camera Permission</Text>
<Text style={styles.subtitle}>
Orca needs camera access to scan the pairing QR code from your desktop.
</Text>
<Pressable style={styles.button} onPress={requestPermission}>
<Text style={styles.buttonText}>Grant Camera Access</Text>
</Pressable>
</View>
)
}
return (
<View style={styles.container}>
<Text style={styles.title}>Scan QR Code</Text>
<Text style={styles.subtitle}>
Open Orca on your computer, go to Settings → Mobile, and scan the QR code shown there.
</Text>
{status === 'scanning' && (
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarCodeScanned}
/>
)}
{status === 'connecting' && (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#3b82f6" />
<Text style={styles.connectingText}>Connecting...</Text>
</View>
)}
{status === 'error' && (
<View style={styles.centered}>
<Text style={styles.errorText}>{errorMessage}</Text>
<Pressable style={styles.button} onPress={retry}>
<Text style={styles.buttonText}>Try Again</Text>
</Pressable>
</View>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0d0d1a',
padding: 24
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#e0e0e0',
marginBottom: 8
},
subtitle: {
fontSize: 14,
color: '#888',
marginBottom: 24,
lineHeight: 20
},
camera: {
flex: 1,
borderRadius: 12,
overflow: 'hidden'
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
connectingText: {
color: '#e0e0e0',
fontSize: 16,
marginTop: 16
},
errorText: {
color: '#ef4444',
fontSize: 16,
textAlign: 'center',
marginBottom: 24,
lineHeight: 22
},
button: {
backgroundColor: '#3b82f6',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600'
}
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+44
View File
@@ -0,0 +1,44 @@
{
"name": "orca-mobile",
"version": "0.0.1",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"lint": "oxlint",
"format": "oxfmt --write .",
"mock-server": "npx tsx scripts/mock-server.ts"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^2.2.0",
"expo": "^55.0.17",
"expo-camera": "^55.0.16",
"expo-constants": "^55.0.15",
"expo-linking": "^55.0.14",
"expo-router": "^55.0.13",
"expo-status-bar": "^55.0.5",
"react": "^19.2.0",
"react-native": "^0.83.6",
"react-native-gesture-handler": "^2.30.1",
"react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.23.0",
"react-native-web": "^0.21.2",
"react-native-worklets": "^0.7.4",
"ws": "^8.18.0",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-native": "^0.73.0",
"@types/ws": "^8.18.1",
"expo-module-scripts": "^55.0.2",
"oxfmt": "^0.47.0",
"oxlint": "^1.62.0",
"tsx": "^4.19.4",
"typescript": "^5.9.3"
}
}
+12340
View File
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env npx tsx
// Why: standalone mock WebSocket server for developing the mobile app without
// a running Orca desktop instance. Responds to the same RPC methods the real
// runtime exposes, with realistic fake data.
import { WebSocketServer, type WebSocket } from 'ws'
const PORT = Number(process.env.PORT) || 6768
const AUTH_TOKEN = 'mock-device-token'
const FAKE_WORKTREES = [
{
worktreeId: 'repo-1::/home/user/projects/acme-api',
repoId: 'repo-1',
repo: 'acme-api',
path: '/home/user/projects/acme-api',
branch: 'feature/auth-refactor',
linkedIssue: 42,
unread: true,
liveTerminalCount: 2,
hasAttachedPty: true,
lastOutputAt: Date.now() - 5000,
preview: '$ claude "refactor the auth module"'
},
{
worktreeId: 'repo-1::/home/user/projects/acme-web',
repoId: 'repo-1',
repo: 'acme-web',
path: '/home/user/projects/acme-web',
branch: 'main',
linkedIssue: null,
unread: false,
liveTerminalCount: 1,
hasAttachedPty: true,
lastOutputAt: Date.now() - 60000,
preview: '$ npm test\nAll tests passed.'
}
]
const FAKE_TERMINALS = [
{
handle: 'term-1',
worktreeId: 'repo-1::/home/user/projects/acme-api',
title: 'Claude — auth refactor',
isActive: true,
hasRunningProcess: true
},
{
handle: 'term-2',
worktreeId: 'repo-1::/home/user/projects/acme-api',
title: 'zsh',
isActive: false,
hasRunningProcess: false
}
]
const FAKE_SCROLLBACK = [
'$ claude "refactor the auth module to use JWT tokens"',
'',
'⏳ Working on it...',
'',
"I'll refactor the auth module. Here's my plan:",
'1. Replace session-based auth with JWT',
'2. Add token refresh endpoint',
'3. Update middleware',
'',
'Let me start by reading the current auth module...',
''
].join('\n')
const STREAMING_CHUNKS = [
'Reading src/auth/middleware.ts...\n',
'Reading src/auth/session.ts...\n',
'\nI see the current implementation uses express-session.\n',
"I'll replace it with jsonwebtoken.\n",
'\nUpdating src/auth/middleware.ts...\n'
]
type RpcRequest = {
id: string
method: string
deviceToken?: string
params?: Record<string, unknown>
}
type RpcResponse = {
id: string
ok: boolean
result?: unknown
error?: { code: string; message: string }
streaming?: true
_meta: { runtimeId: string }
}
function success(id: string, result: unknown, streaming?: boolean): RpcResponse {
const resp: RpcResponse = { id, ok: true, result, _meta: { runtimeId: 'mock-runtime' } }
if (streaming) {
resp.streaming = true
}
return resp
}
function error(id: string, code: string, message: string): RpcResponse {
return { id, ok: false, error: { code, message }, _meta: { runtimeId: 'mock-runtime' } }
}
function handleRequest(request: RpcRequest, ws: WebSocket): void {
if (request.deviceToken !== AUTH_TOKEN) {
ws.send(JSON.stringify(error(request.id, 'unauthorized', 'Invalid device token')))
return
}
switch (request.method) {
case 'status.get':
ws.send(
JSON.stringify(
success(request.id, {
runtimeId: 'mock-runtime',
graphStatus: 'ready',
windowCount: 1,
tabCount: 2,
terminalCount: 2
})
)
)
break
case 'worktree.ps':
ws.send(
JSON.stringify(
success(request.id, {
worktrees: FAKE_WORKTREES,
totalCount: FAKE_WORKTREES.length,
truncated: false
})
)
)
break
case 'terminal.list':
ws.send(
JSON.stringify(
success(request.id, {
terminals: FAKE_TERMINALS,
totalCount: FAKE_TERMINALS.length,
truncated: false
})
)
)
break
case 'terminal.subscribe': {
ws.send(
JSON.stringify(
success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false })
)
)
let chunkIndex = 0
const interval = setInterval(() => {
if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) {
clearInterval(interval)
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(success(request.id, { type: 'end' })))
}
return
}
ws.send(
JSON.stringify(
success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true)
)
)
chunkIndex++
}, 500)
break
}
case 'terminal.send':
ws.send(JSON.stringify(success(request.id, { send: { handle: 'term-1', ok: true } })))
break
case 'terminal.unsubscribe':
ws.send(JSON.stringify(success(request.id, { unsubscribed: true })))
break
default:
ws.send(
JSON.stringify(error(request.id, 'method_not_found', `Unknown method: ${request.method}`))
)
}
}
const wss = new WebSocketServer({ port: PORT })
wss.on('connection', (ws) => {
console.log('[mock] Client connected')
ws.on('message', (data) => {
const msg = typeof data === 'string' ? data : data.toString('utf-8')
let request: RpcRequest
try {
request = JSON.parse(msg) as RpcRequest
} catch {
ws.send(JSON.stringify(error('unknown', 'bad_request', 'Invalid JSON')))
return
}
console.log(`[mock] ${request.method} (id: ${request.id})`)
handleRequest(request, ws)
})
ws.on('close', () => {
console.log('[mock] Client disconnected')
})
ws.on('error', () => {
ws.close()
})
})
console.log(`[mock] Orca mock server listening on ws://localhost:${PORT}`)
console.log(`[mock] Auth token: ${AUTH_TOKEN}`)
console.log(`[mock] Try: npx wscat -c ws://localhost:${PORT}`)
console.log(`[mock] Then send: {"id":"1","deviceToken":"${AUTH_TOKEN}","method":"status.get"}`)
+40
View File
@@ -0,0 +1,40 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { HostProfile } from './types'
const STORAGE_KEY = 'orca:hosts'
export async function loadHosts(): Promise<HostProfile[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) return []
try {
return JSON.parse(raw) as HostProfile[]
} catch {
return []
}
}
export async function saveHost(host: HostProfile): Promise<void> {
const hosts = await loadHosts()
const index = hosts.findIndex((h) => h.id === host.id)
if (index >= 0) {
hosts[index] = host
} else {
hosts.push(host)
}
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
}
export async function removeHost(hostId: string): Promise<void> {
const hosts = await loadHosts()
const filtered = hosts.filter((h) => h.id !== hostId)
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
}
export async function updateLastConnected(hostId: string): Promise<void> {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (host) {
host.lastConnected = Date.now()
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
}
}
+12
View File
@@ -0,0 +1,12 @@
export { connect, type RpcClient } from './rpc-client'
export { loadHosts, saveHost, removeHost, updateLastConnected } from './host-store'
export type {
RpcRequest,
RpcResponse,
RpcSuccess,
RpcFailure,
ConnectionState,
HostProfile,
PairingOffer
} from './types'
export { PairingOfferV1, PAIRING_OFFER_VERSION } from './types'
+16
View File
@@ -0,0 +1,16 @@
import { PairingOfferV1, type PairingOffer } from './types'
export function decodePairingUrl(url: string): PairingOffer | null {
try {
const hashIndex = url.indexOf('#')
if (!url.startsWith('orca://pair') || hashIndex === -1) return null
const base64url = url.slice(hashIndex + 1)
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const json = atob(base64)
const parsed = JSON.parse(json)
return PairingOfferV1.parse(parsed)
} catch {
return null
}
}
+232
View File
@@ -0,0 +1,232 @@
import type { RpcRequest, RpcResponse, RpcSuccess, ConnectionState } from './types'
type PendingRequest = {
resolve: (response: RpcResponse) => void
reject: (error: Error) => void
}
type StreamingListener = (result: unknown) => void
export type RpcClient = {
sendRequest: (method: string, params?: unknown) => Promise<RpcResponse>
subscribe: (method: string, params: unknown, onData: StreamingListener) => () => void
getState: () => ConnectionState
onStateChange: (listener: (state: ConnectionState) => void) => () => void
close: () => void
}
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
const REQUEST_TIMEOUT_MS = 30_000
export function connect(
endpoint: string,
deviceToken: string,
onStateChange?: (state: ConnectionState) => void
): RpcClient {
let ws: WebSocket | null = null
let state: ConnectionState = 'disconnected'
let requestCounter = 0
let reconnectAttempt = 0
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let intentionallyClosed = false
const pending = new Map<string, PendingRequest>()
const streamListeners = new Map<string, StreamingListener>()
const stateListeners = new Set<(state: ConnectionState) => void>()
// Callers that are waiting for the socket to reach 'connected' state
const connectWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = []
if (onStateChange) {
stateListeners.add(onStateChange)
}
function setState(next: ConnectionState) {
if (state === next) return
state = next
if (next === 'connected') {
for (const w of connectWaiters.splice(0)) w.resolve()
} else if (next === 'disconnected') {
for (const w of connectWaiters.splice(0)) w.reject(new Error('Connection closed'))
}
for (const listener of stateListeners) {
listener(next)
}
}
function waitForConnected(): Promise<void> {
if (state === 'connected') return Promise.resolve()
if (intentionallyClosed) return Promise.reject(new Error('Client closed'))
return new Promise((resolve, reject) => {
connectWaiters.push({ resolve, reject })
})
}
function nextId(): string {
return `rpc-${++requestCounter}-${Date.now()}`
}
function openConnection() {
if (intentionallyClosed) return
setState('connecting')
ws = new WebSocket(endpoint)
ws.onopen = () => {
reconnectAttempt = 0
setState('connected')
}
ws.onmessage = (event) => {
let response: RpcResponse
try {
response = JSON.parse(typeof event.data === 'string' ? event.data : String(event.data))
} catch {
return
}
const isStreaming = response.ok && (response as RpcSuccess).streaming === true
if (isStreaming) {
const listener = streamListeners.get(response.id)
if (listener && response.ok) {
listener((response as RpcSuccess).result)
}
return
}
// Non-streaming: check if it's a final streaming message (type: 'end')
// or a one-shot response
if (response.ok) {
const result = (response as RpcSuccess).result as Record<string, unknown> | null
if (result && result.type === 'end') {
const listener = streamListeners.get(response.id)
if (listener) {
listener(result)
streamListeners.delete(response.id)
return
}
}
// Scrollback (first message from terminal.subscribe) also goes to stream listener
if (result && result.type === 'scrollback') {
const listener = streamListeners.get(response.id)
if (listener) {
listener(result)
return
}
}
}
const req = pending.get(response.id)
if (req) {
pending.delete(response.id)
req.resolve(response)
}
}
ws.onclose = () => {
ws = null
if (intentionallyClosed) {
setState('disconnected')
rejectAllPending('Connection closed')
return
}
setState('reconnecting')
scheduleReconnect()
}
ws.onerror = () => {
// onclose will fire after this
}
}
function scheduleReconnect() {
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]!
reconnectAttempt++
reconnectTimer = setTimeout(() => {
reconnectTimer = null
openConnection()
}, delay)
}
function rejectAllPending(reason: string) {
for (const [id, req] of pending) {
req.reject(new Error(reason))
pending.delete(id)
}
}
function sendRaw(request: RpcRequest) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(request))
}
}
openConnection()
return {
async sendRequest(method: string, params?: unknown): Promise<RpcResponse> {
await waitForConnected()
return new Promise((resolve, reject) => {
const id = nextId()
const timeout = setTimeout(() => {
pending.delete(id)
reject(new Error(`Request timed out: ${method}`))
}, REQUEST_TIMEOUT_MS)
pending.set(id, {
resolve: (response) => {
clearTimeout(timeout)
resolve(response)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
}
})
sendRaw({ id, deviceToken, method, params })
})
},
subscribe(method: string, params: unknown, onData: StreamingListener): () => void {
const id = nextId()
streamListeners.set(id, onData)
sendRaw({ id, deviceToken, method, params })
return () => {
streamListeners.delete(id)
sendRaw({
id: nextId(),
deviceToken,
method: 'terminal.unsubscribe',
params: { subscriptionId: id }
})
}
},
getState(): ConnectionState {
return state
},
onStateChange(listener: (state: ConnectionState) => void): () => void {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
},
close() {
intentionallyClosed = true
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (ws) {
ws.close()
ws = null
}
setState('disconnected')
rejectAllPending('Client closed')
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import { z } from 'zod'
export type RpcRequest = {
id: string
deviceToken: string
method: string
params?: unknown
}
export type RpcSuccess = {
id: string
ok: true
result: unknown
streaming?: true
_meta: { runtimeId: string }
}
export type RpcFailure = {
id: string
ok: false
error: { code: string; message: string; data?: unknown }
_meta: { runtimeId: string }
}
export type RpcResponse = RpcSuccess | RpcFailure
export const PAIRING_OFFER_VERSION = 1
export const PairingOfferV1 = z.object({
v: z.literal(PAIRING_OFFER_VERSION),
endpoint: z.string().min(1),
deviceToken: z.string().min(1),
certFingerprint: z.string().startsWith('sha256:')
})
export type PairingOffer = z.infer<typeof PairingOfferV1>
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
export type HostProfile = {
id: string
name: string
endpoint: string
deviceToken: string
certFingerprint: string
lastConnected: number
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx"]
}
+2
View File
@@ -87,6 +87,7 @@
"monaco-editor": "^0.55.1",
"node-pty": "^1.1.0",
"pdfjs-dist": "^5.6.205",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
@@ -115,6 +116,7 @@
"@stablyai/playwright-test": "^2.1.13",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^25.5.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ssh2": "^1.15.5",
+148
View File
@@ -160,6 +160,9 @@ importers:
pdfjs-dist:
specifier: ^5.6.205
version: 5.6.205
qrcode:
specifier: ^1.5.4
version: 1.5.4
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -239,6 +242,9 @@ importers:
'@types/node':
specifier: ^25.5.0
version: 25.5.0
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react':
specifier: ^19.2.14
version: 19.2.14
@@ -2819,6 +2825,9 @@ packages:
'@types/plist@3.0.5':
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -3217,6 +3226,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001780:
resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
@@ -3298,6 +3311,9 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -3612,6 +3628,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -3690,6 +3710,9 @@ packages:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dir-compare@4.2.0:
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
@@ -3981,6 +4004,10 @@ packages:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@@ -4607,6 +4634,10 @@ packages:
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
engines: {node: '>=20.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
@@ -5117,10 +5148,18 @@ packages:
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
engines: {node: '>=8'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-map@7.0.4:
resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==}
engines: {node: '>=18'}
@@ -5129,6 +5168,10 @@ packages:
resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
engines: {node: '>=8'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -5163,6 +5206,10 @@ packages:
path-data-parser@0.1.0:
resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
@@ -5231,6 +5278,10 @@ packages:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
@@ -5331,6 +5382,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
engines: {node: '>=0.6'}
@@ -5477,6 +5533,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resedit@1.7.2:
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
engines: {node: '>=12', npm: '>=6'}
@@ -5608,6 +5667,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -6176,6 +6238,9 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -6235,6 +6300,9 @@ packages:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -6254,10 +6322,18 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -8667,6 +8743,10 @@ snapshots:
xmlbuilder: 15.1.1
optional: true
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 25.5.0
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -9122,6 +9202,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001780: {}
ccount@2.0.1: {}
@@ -9191,6 +9273,12 @@ snapshots:
cli-width@4.1.0: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -9505,6 +9593,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
@@ -9569,6 +9659,8 @@ snapshots:
diff@8.0.3: {}
dijkstrajs@1.0.3: {}
dir-compare@4.2.0:
dependencies:
minimatch: 3.1.5
@@ -10004,6 +10096,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@@ -10635,6 +10732,10 @@ snapshots:
rfdc: 1.4.1
wrap-ansi: 9.0.2
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
lodash-es@4.18.1: {}
lodash.escaperegexp@4.1.2: {}
@@ -11452,10 +11553,18 @@ snapshots:
p-cancelable@2.1.1: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-map@7.0.4: {}
p-retry@4.6.2:
@@ -11463,6 +11572,8 @@ snapshots:
'@types/retry': 0.12.0
retry: 0.13.1
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
package-manager-detector@1.6.0: {}
@@ -11500,6 +11611,8 @@ snapshots:
path-data-parser@0.1.0: {}
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
path-key@3.1.1: {}
@@ -11554,6 +11667,8 @@ snapshots:
base64-js: 1.5.1
xmlbuilder: 15.1.1
pngjs@5.0.0: {}
pngjs@7.0.0: {}
points-on-curve@0.2.0: {}
@@ -11688,6 +11803,12 @@ snapshots:
punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.0:
dependencies:
side-channel: 1.1.0
@@ -11952,6 +12073,8 @@ snapshots:
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resedit@1.7.2:
dependencies:
pe-library: 0.4.1
@@ -12115,6 +12238,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
setprototypeof@1.2.0: {}
shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3):
@@ -12685,6 +12810,8 @@ snapshots:
web-streams-polyfill@3.3.3: {}
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -12737,6 +12864,8 @@ snapshots:
xmlbuilder@15.1.1: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
@@ -12747,8 +12876,27 @@ snapshots:
yaml@2.8.3: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@21.1.1: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@17.7.2:
dependencies:
cliui: 8.0.1
+6 -2
View File
@@ -1,14 +1,18 @@
import { homedir } from 'os'
import { join } from 'path'
import { readFileSync } from 'fs'
import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
import {
findTransport,
getRuntimeMetadataPath,
type RuntimeMetadata
} from '../../shared/runtime-bootstrap'
import { RuntimeClientError } from './types'
export function readMetadata(userDataPath: string): RuntimeMetadata {
const metadataPath = getRuntimeMetadataPath(userDataPath)
try {
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null
if (!metadata?.transport || !metadata.authToken) {
if (!metadata || !findTransport(metadata, 'unix', 'named-pipe') || !metadata.authToken) {
throw new RuntimeClientError(
'runtime_unavailable',
`Orca runtime metadata is incomplete at ${metadataPath}`
+1 -1
View File
@@ -7,7 +7,7 @@ export async function getCliStatus(
userDataPath: string
): Promise<RuntimeRpcSuccess<CliStatusResult>> {
const metadata = tryReadMetadata(userDataPath)
if (!metadata?.transport || !metadata.authToken) {
if (!metadata?.transports?.length || !metadata.authToken) {
return buildCliStatusResponse({
app: {
running: false,
+12 -6
View File
@@ -1,6 +1,6 @@
import { createConnection } from 'net'
import { randomUUID } from 'crypto'
import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
import { RuntimeRpcEnvelopeSchema } from './envelope-schema'
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
@@ -11,7 +11,17 @@ export async function sendRequest<TResult>(
timeoutMs: number
): Promise<RuntimeRpcResponse<TResult>> {
return await new Promise((resolve, reject) => {
const socket = createConnection(getTransportEndpoint(metadata.transport!))
const transport = findTransport(metadata, 'unix', 'named-pipe')
if (!transport) {
reject(
new RuntimeClientError(
'runtime_unavailable',
'No compatible transport found in Orca runtime metadata.'
)
)
return
}
const socket = createConnection(transport.endpoint)
let buffer = ''
const requestId = randomUUID()
@@ -104,7 +114,3 @@ export async function sendRequest<TResult>(
})
})
}
function getTransportEndpoint(transport: RuntimeTransportMetadata): string {
return transport.endpoint
}
@@ -273,7 +273,7 @@ describe('Browser automation pipeline (integration)', () => {
await server.start()
const metadata = readRuntimeMetadata(userDataPath)!
endpoint = metadata.transport!.endpoint
endpoint = metadata.transports[0]!.endpoint
authToken = metadata.authToken!
})
@@ -521,7 +521,7 @@ describe('Browser automation pipeline (integration)', () => {
await server2.start()
const metadata2 = readRuntimeMetadata(userDataPath2)!
const res = await sendRequest(metadata2.transport!.endpoint, {
const res = await sendRequest(metadata2.transports[0]!.endpoint, {
id: 'req_no_tab',
authToken: metadata2.authToken,
method: 'browser.snapshot'
+2
View File
@@ -19,6 +19,7 @@ import {
import { recordPendingDaemonTransitionNotice, setAppRuntimeFlags } from './ipc/app'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { registerCoreHandlers } from './ipc/register-core-handlers'
import { registerMobileHandlers } from './ipc/mobile'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
@@ -389,6 +390,7 @@ app.whenReady().then(async () => {
runtime,
userDataPath: app.getPath('userData')
})
registerMobileHandlers(runtimeRpc)
// Why: persistent terminal sessions (the out-of-process daemon) are gated
// behind an experimental setting that defaults to OFF. Users on v1.3.0 had
+71
View File
@@ -0,0 +1,71 @@
import { ipcMain } from 'electron'
import QRCode from 'qrcode'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
// 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.
export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
ipcMain.handle('mobile:getPairingQR', async () => {
const endpoint = rpcServer.getWebSocketEndpoint()
const fingerprint = rpcServer.getTlsFingerprint()
const registry = rpcServer.getDeviceRegistry()
if (!endpoint || !fingerprint || !registry) {
return { available: false as const }
}
const device = registry.addDevice(`Mobile ${new Date().toLocaleDateString()}`)
const url = encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint,
deviceToken: device.token,
certFingerprint: fingerprint
})
const qrDataUrl = await QRCode.toDataURL(url, {
errorCorrectionLevel: 'M',
margin: 2,
width: 256
})
return {
available: true as const,
qrDataUrl,
endpoint,
deviceId: device.deviceId
}
})
ipcMain.handle('mobile:listDevices', () => {
const registry = rpcServer.getDeviceRegistry()
if (!registry) {
return { devices: [] }
}
return {
devices: registry.listDevices().map((d) => ({
deviceId: d.deviceId,
name: d.name,
pairedAt: d.pairedAt,
lastSeenAt: d.lastSeenAt
}))
}
})
ipcMain.handle('mobile:revokeDevice', (_event, args: { deviceId: string }) => {
const registry = rpcServer.getDeviceRegistry()
if (!registry) {
return { revoked: false }
}
return { revoked: registry.removeDevice(args.deviceId) }
})
ipcMain.handle('mobile:isWebSocketReady', () => {
return {
ready: rpcServer.getWebSocketEndpoint() !== null,
endpoint: rpcServer.getWebSocketEndpoint()
}
})
}
+83
View File
@@ -0,0 +1,83 @@
// Why: per-device tokens replace the shared runtime auth token for WebSocket
// (mobile) connections. Each paired device gets its own revocable token so
// compromising one device doesn't expose others. The registry is a simple
// JSON file with hardened permissions matching the runtime metadata pattern.
import { randomBytes, randomUUID } from 'crypto'
import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs'
import { join } from 'path'
const DEVICE_REGISTRY_FILENAME = 'orca-devices.json'
export type DeviceEntry = {
deviceId: string
name: string
token: string
pairedAt: number
lastSeenAt: number
}
export class DeviceRegistry {
private readonly registryPath: string
private devices: DeviceEntry[] = []
constructor(userDataPath: string) {
this.registryPath = join(userDataPath, DEVICE_REGISTRY_FILENAME)
this.load()
}
addDevice(name: string): DeviceEntry {
const entry: DeviceEntry = {
deviceId: randomUUID(),
name,
token: randomBytes(24).toString('hex'),
pairedAt: Date.now(),
lastSeenAt: 0
}
this.devices.push(entry)
this.save()
return entry
}
removeDevice(deviceId: string): boolean {
const before = this.devices.length
this.devices = this.devices.filter((d) => d.deviceId !== deviceId)
if (this.devices.length < before) {
this.save()
return true
}
return false
}
listDevices(): readonly DeviceEntry[] {
return this.devices
}
validateToken(token: string): DeviceEntry | null {
return this.devices.find((d) => d.token === token) ?? null
}
updateLastSeen(deviceId: string): void {
const device = this.devices.find((d) => d.deviceId === deviceId)
if (device) {
device.lastSeenAt = Date.now()
this.save()
}
}
private load(): void {
if (!existsSync(this.registryPath)) {
this.devices = []
return
}
try {
this.devices = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[]
} catch {
this.devices = []
}
}
private save(): void {
writeFileSync(this.registryPath, JSON.stringify(this.devices, null, 2), { mode: 0o600 })
chmodSync(this.registryPath, 0o600)
}
}
+51
View File
@@ -221,6 +221,11 @@ export class OrcaRuntimeService {
private agentBrowserBridge: AgentBrowserBridge | null = null
private resolvedWorktreeCache: ResolvedWorktreeCache | null = null
private agentDetector: AgentDetector | null = null
// Why: mobile clients subscribe to terminal output via terminal.subscribe.
// These listeners fire on every onPtyData call, enabling real-time streaming
// without polling. Keyed by ptyId for O(1) lookup per data event.
private dataListeners = new Map<string, Set<(data: string) => void>>()
private subscriptionCleanups = new Map<string, () => void>()
constructor(store: RuntimeStore | null = null, stats?: StatsCollector) {
this.store = store
@@ -378,6 +383,52 @@ export class OrcaRuntimeService {
}
}
}
const listeners = this.dataListeners.get(ptyId)
if (listeners) {
for (const listener of listeners) {
listener(data)
}
}
}
subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void {
let listeners = this.dataListeners.get(ptyId)
if (!listeners) {
listeners = new Set()
this.dataListeners.set(ptyId, listeners)
}
listeners.add(listener)
return () => {
listeners.delete(listener)
if (listeners.size === 0) {
this.dataListeners.delete(ptyId)
}
}
}
resolveLeafForHandle(handle: string): { ptyId: string | null } | null {
const record = this.handles.get(handle)
if (!record) {
return null
}
const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId))
if (!leaf) {
return null
}
return { ptyId: leaf.ptyId }
}
registerSubscriptionCleanup(subscriptionId: string, cleanup: () => void): void {
this.subscriptionCleanups.set(subscriptionId, cleanup)
}
cleanupSubscription(subscriptionId: string): void {
const cleanup = this.subscriptionCleanups.get(subscriptionId)
if (cleanup) {
this.subscriptionCleanups.delete(subscriptionId)
cleanup()
}
}
onPtyExit(ptyId: string, exitCode: number): void {
+47 -3
View File
@@ -13,6 +13,7 @@ export type RpcSuccess = {
id: string
ok: true
result: unknown
streaming?: true
_meta: RpcEnvelopeMeta
}
@@ -69,10 +70,53 @@ export function defineMethod<TSchema extends ZodType | null>(
}
}
export type RpcRegistry = ReadonlyMap<string, RpcMethod>
export type RpcStreamingHandler<TParams> = (
params: TParams,
ctx: RpcContext,
emit: (result: unknown) => void
) => Promise<void>
export function buildRegistry(methods: readonly RpcMethod[]): RpcRegistry {
const registry = new Map<string, RpcMethod>()
// Why: streaming methods emit multiple responses over a long-lived connection.
// The `stream` flag lets the dispatcher distinguish them from one-shot methods
// and route them to the emit-based call path instead of the Promise-based one.
export type RpcStreamingMethod = {
readonly name: string
readonly params: ZodType | null
readonly stream: true
readonly handler: (
params: unknown,
ctx: RpcContext,
emit: (result: unknown) => void
) => Promise<void>
}
type DefineStreamingMethodSpec<TSchema extends ZodType | null> = {
name: string
params: TSchema
handler: RpcStreamingHandler<TSchema extends ZodType ? TSchema['_output'] : void>
}
export function defineStreamingMethod<TSchema extends ZodType | null>(
spec: DefineStreamingMethodSpec<TSchema>
): RpcStreamingMethod {
return {
name: spec.name,
params: spec.params,
stream: true,
handler: spec.handler as RpcStreamingMethod['handler']
}
}
export type RpcAnyMethod = RpcMethod | RpcStreamingMethod
export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod {
return 'stream' in method && method.stream === true
}
export type RpcRegistry = ReadonlyMap<string, RpcAnyMethod>
export function buildRegistry(methods: readonly RpcAnyMethod[]): RpcRegistry {
const registry = new Map<string, RpcAnyMethod>()
for (const method of methods) {
if (registry.has(method.name)) {
throw new Error(`duplicate_rpc_method:${method.name}`)
+96 -24
View File
@@ -6,8 +6,9 @@ import {
ZodError,
buildRegistry,
formatZodError,
isStreamingMethod,
type RpcAnyMethod,
type RpcEnvelopeMeta,
type RpcMethod,
type RpcRegistry,
type RpcRequest,
type RpcResponse
@@ -18,7 +19,7 @@ import type { OrcaRuntimeService } from '../orca-runtime'
export type DispatcherOptions = {
runtime: OrcaRuntimeService
methods?: readonly RpcMethod[]
methods?: readonly RpcAnyMethod[]
}
export class RpcDispatcher {
@@ -42,36 +43,107 @@ export class RpcDispatcher {
)
}
let parsedParams: unknown
if (method.params === null) {
parsedParams = undefined
} else {
const rawParams = request.params ?? {}
const result = method.params.safeParse(rawParams)
if (!result.success) {
return errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error))
}
parsedParams = result.data
const parsedParams = this.parseParams(request, method, meta)
if (parsedParams.error) {
return parsedParams.error
}
// Why: streaming methods are not supported over one-shot transports like
// Unix sockets. They require a reply function that can be called multiple
// times, which is only available via dispatchStreaming.
if (isStreamingMethod(method)) {
return errorResponse(
request.id,
meta,
'method_not_supported',
`Method ${request.method} requires a streaming transport`
)
}
try {
const result = await method.handler(parsedParams, { runtime: this.runtime })
const result = await method.handler(parsedParams.value, { runtime: this.runtime })
return successResponse(request.id, meta, result)
} catch (error) {
// Why: browser methods throw BrowserError with a structured `code`;
// every other runtime error has a plain-message code. Routing by method
// prefix keeps the mapping a single decision rather than a per-method
// flag callers must remember to set.
if (request.method.startsWith('browser.')) {
return mapBrowserError(request.id, meta, error)
}
if (error instanceof ZodError) {
return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error))
}
return mapRuntimeError(request.id, meta, error)
return this.mapError(request, meta, error)
}
}
// Why: streaming dispatch sends multiple responses through the reply callback
// instead of returning a single Promise. This enables terminal.subscribe and
// other subscription-style methods that push data over time.
async dispatchStreaming(request: RpcRequest, reply: (response: string) => void): Promise<void> {
const meta = this.meta()
const method = this.registry.get(request.method)
if (!method) {
reply(
JSON.stringify(
errorResponse(request.id, meta, 'method_not_found', `Unknown method: ${request.method}`)
)
)
return
}
const parsedParams = this.parseParams(request, method, meta)
if (parsedParams.error) {
reply(JSON.stringify(parsedParams.error))
return
}
if (!isStreamingMethod(method)) {
try {
const result = await method.handler(parsedParams.value, { runtime: this.runtime })
reply(JSON.stringify(successResponse(request.id, meta, result)))
} catch (error) {
reply(JSON.stringify(this.mapError(request, meta, error)))
}
return
}
const emit = (result: unknown): void => {
const response = successResponse(request.id, meta, result)
response.streaming = true
reply(JSON.stringify(response))
}
try {
await method.handler(parsedParams.value, { runtime: this.runtime }, emit)
} catch (error) {
reply(JSON.stringify(this.mapError(request, meta, error)))
}
}
private parseParams(
request: RpcRequest,
method: RpcAnyMethod,
meta: RpcEnvelopeMeta
): { value: unknown; error?: undefined } | { value?: undefined; error: RpcResponse } {
if (method.params === null) {
return { value: undefined }
}
const rawParams = request.params ?? {}
const result = method.params.safeParse(rawParams)
if (!result.success) {
return {
error: errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error))
}
}
return { value: result.data }
}
private mapError(request: RpcRequest, meta: RpcEnvelopeMeta, error: unknown): RpcResponse {
// Why: browser methods throw BrowserError with a structured `code`;
// every other runtime error has a plain-message code. Routing by method
// prefix keeps the mapping a single decision rather than a per-method
// flag callers must remember to set.
if (request.method.startsWith('browser.')) {
return mapBrowserError(request.id, meta, error)
}
if (error instanceof ZodError) {
return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error))
}
return mapRuntimeError(request.id, meta, error)
}
private meta(): RpcEnvelopeMeta {
return { runtimeId: this.runtime.getRuntimeId() }
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { RpcMethod } from '../core'
import type { RpcAnyMethod } from '../core'
import { STATUS_METHODS } from './status'
import { REPO_METHODS } from './repo'
import { WORKTREE_METHODS } from './worktree'
@@ -9,7 +9,7 @@ import { BROWSER_EXTRA_METHODS } from './browser-extras'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
// auditing the security boundary or wiring new CLI commands.
export const ALL_RPC_METHODS: readonly RpcMethod[] = [
export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...STATUS_METHODS,
...REPO_METHODS,
...WORKTREE_METHODS,
+52 -2
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
const TerminalHandle = z.object({
@@ -77,7 +77,13 @@ const TerminalStop = z.object({
worktree: requiredString('Missing worktree selector')
})
export const TERMINAL_METHODS: RpcMethod[] = [
const TerminalSubscribe = TerminalHandle.extend({})
const TerminalUnsubscribe = z.object({
subscriptionId: requiredString('Missing subscription ID')
})
export const TERMINAL_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.list',
params: TerminalListParams,
@@ -170,5 +176,49 @@ export const TERMINAL_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) => ({
close: await runtime.closeTerminal(params.terminal)
})
}),
// Why: terminal.subscribe streams live terminal output over WebSocket.
// It sends initial scrollback, then live data chunks as they arrive.
// Only works over streaming-capable transports (WebSocket, not Unix socket).
defineStreamingMethod({
name: 'terminal.subscribe',
params: TerminalSubscribe,
handler: async (params, { runtime }, emit) => {
const read = await runtime.readTerminal(params.terminal)
emit({ type: 'scrollback', lines: read.tail, truncated: read.truncated })
const leaf = runtime.resolveLeafForHandle(params.terminal)
if (!leaf?.ptyId) {
emit({ type: 'end' })
return
}
// Why: the handler returns a Promise that never resolves (until
// unsubscribe or disconnect). The emit callback pushes data chunks
// as they arrive. Cleanup happens via the unsubscribe mechanism or
// connection-scoped cleanup in the transport layer.
await new Promise<void>((resolve) => {
const unsubscribe = runtime.subscribeToTerminalData(leaf.ptyId!, (data) => {
emit({ type: 'data', chunk: data })
})
// Why: store the cleanup function so terminal.unsubscribe and
// connection-close cleanup can call it.
const subscriptionId = params.terminal
runtime.registerSubscriptionCleanup(subscriptionId, () => {
unsubscribe()
emit({ type: 'end' })
resolve()
})
})
}
}),
defineMethod({
name: 'terminal.unsubscribe',
params: TerminalUnsubscribe,
handler: async (params, { runtime }) => {
runtime.cleanupSubscription(params.subscriptionId)
return { unsubscribed: true }
}
})
]
+9
View File
@@ -0,0 +1,9 @@
// Why: the transport interface decouples the RPC server from a specific
// transport mechanism (Unix socket, WebSocket, named pipe). Each transport
// owns its own connection lifecycle — the RPC server just binds message
// handling to whatever transports are registered.
export type RpcTransport = {
start(): Promise<void>
stop(): Promise<void>
onMessage(handler: (msg: string, reply: (response: string) => void) => void): void
}
@@ -0,0 +1,122 @@
// Why: this is the original Unix socket / named pipe transport extracted from
// runtime-rpc.ts. It preserves the exact same behavior: newline-delimited JSON,
// 30s idle timeout, 1MB max message, 32 max connections, chmod 0o600 on Unix.
import { createServer, type Server, type Socket } from 'net'
import { chmodSync, existsSync, rmSync } from 'fs'
import type { RpcTransport } from './transport'
const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024
const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000
const MAX_RUNTIME_RPC_CONNECTIONS = 32
export type UnixSocketTransportOptions = {
endpoint: string
kind: 'unix' | 'named-pipe'
}
export class UnixSocketTransport implements RpcTransport {
private readonly endpoint: string
private readonly kind: 'unix' | 'named-pipe'
private server: Server | null = null
private messageHandler: ((msg: string, reply: (response: string) => void) => void) | null = null
constructor({ endpoint, kind }: UnixSocketTransportOptions) {
this.endpoint = endpoint
this.kind = kind
}
onMessage(handler: (msg: string, reply: (response: string) => void) => void): void {
this.messageHandler = handler
}
async start(): Promise<void> {
if (this.server) {
return
}
if (this.kind === 'unix' && existsSync(this.endpoint)) {
rmSync(this.endpoint, { force: true })
}
const server = createServer((socket) => {
this.handleConnection(socket)
})
server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(this.endpoint, () => {
server.off('error', reject)
resolve()
})
})
if (this.kind === 'unix') {
chmodSync(this.endpoint, 0o600)
}
this.server = server
}
async stop(): Promise<void> {
const server = this.server
this.server = null
if (!server) {
return
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
if (this.kind === 'unix' && existsSync(this.endpoint)) {
rmSync(this.endpoint, { force: true })
}
}
private handleConnection(socket: Socket): void {
let buffer = ''
let oversized = false
socket.setEncoding('utf8')
socket.setNoDelay(true)
socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => {
socket.destroy()
})
socket.on('error', () => {
socket.destroy()
})
socket.on('data', (chunk: string) => {
if (oversized) {
return
}
buffer += chunk
// Why: the Orca runtime lives in Electron main, so it must reject
// oversized local RPC frames instead of letting a local client grow an
// unbounded buffer and stall the app.
if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) {
oversized = true
this.messageHandler?.('', (response) => {
socket.write(`${response}\n`)
socket.end()
})
return
}
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1) {
const rawMessage = buffer.slice(0, newlineIndex).trim()
buffer = buffer.slice(newlineIndex + 1)
if (rawMessage) {
this.messageHandler?.(rawMessage, (response) => {
socket.write(`${response}\n`)
})
}
newlineIndex = buffer.indexOf('\n')
}
})
}
}
+232
View File
@@ -0,0 +1,232 @@
import { mkdtempSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it, afterEach } from 'vitest'
import WebSocket from 'ws'
import { WebSocketTransport } from './ws-transport'
import { loadOrCreateTlsCertificate } from '../tls-certificate'
// Why: disable TLS verification for self-signed certs in tests.
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
function makeTls() {
const userDataPath = mkdtempSync(join(tmpdir(), 'ws-transport-test-'))
return loadOrCreateTlsCertificate(userDataPath)
}
function findFreePort(): number {
// Why: use port 0 to let the OS assign an available port, but the transport
// needs an explicit port. Use a high random port to minimize collisions.
return 30000 + Math.floor(Math.random() * 20000)
}
describe('WebSocketTransport', () => {
const transports: WebSocketTransport[] = []
afterEach(async () => {
await Promise.all(transports.map((t) => t.stop().catch(() => {})))
transports.length = 0
})
async function createTransport(
handler?: (msg: string, reply: (response: string) => void) => void
) {
const tls = makeTls()
const port = findFreePort()
const transport = new WebSocketTransport({
host: '127.0.0.1',
port,
tlsCert: tls.cert,
tlsKey: tls.key
})
if (handler) {
transport.onMessage(handler)
}
transports.push(transport)
return { transport, port, tls }
}
function connectWs(port: number): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`wss://127.0.0.1:${port}`, {
rejectUnauthorized: false
})
ws.once('open', () => resolve(ws))
ws.once('error', reject)
})
}
function sendAndReceive(ws: WebSocket, message: string): Promise<string> {
return new Promise((resolve) => {
ws.once('message', (data) => {
resolve(typeof data === 'string' ? data : data.toString('utf-8'))
})
ws.send(message)
})
}
it('starts and stops cleanly', async () => {
const { transport } = await createTransport()
await transport.start()
await transport.stop()
})
it('handles request/response round-trip', async () => {
const { transport, port } = await createTransport((msg, reply) => {
const request = JSON.parse(msg)
reply(JSON.stringify({ id: request.id, ok: true, result: { echo: true } }))
})
await transport.start()
const ws = await connectWs(port)
const response = await sendAndReceive(
ws,
JSON.stringify({ id: 'req-1', method: 'test', deviceToken: 'tok' })
)
expect(JSON.parse(response)).toMatchObject({
id: 'req-1',
ok: true,
result: { echo: true }
})
ws.close()
})
it('supports multiple concurrent connections', async () => {
const { transport, port } = await createTransport((msg, reply) => {
const request = JSON.parse(msg)
reply(JSON.stringify({ id: request.id, ok: true }))
})
await transport.start()
const clients = await Promise.all([connectWs(port), connectWs(port), connectWs(port)])
const responses = await Promise.all(
clients.map((ws, i) => sendAndReceive(ws, JSON.stringify({ id: `req-${i}`, method: 'test' })))
)
for (let i = 0; i < 3; i++) {
expect(JSON.parse(responses[i]!)).toMatchObject({ id: `req-${i}`, ok: true })
}
for (const ws of clients) {
ws.close()
}
})
it('multiplexes multiple requests on a single connection', async () => {
const { transport, port } = await createTransport((msg, reply) => {
const request = JSON.parse(msg)
reply(JSON.stringify({ id: request.id, ok: true, result: { method: request.method } }))
})
await transport.start()
const ws = await connectWs(port)
const r1 = sendAndReceive(ws, JSON.stringify({ id: 'a', method: 'first' }))
const resp1 = JSON.parse(await r1)
expect(resp1).toMatchObject({ id: 'a', result: { method: 'first' } })
const r2 = sendAndReceive(ws, JSON.stringify({ id: 'b', method: 'second' }))
const resp2 = JSON.parse(await r2)
expect(resp2).toMatchObject({ id: 'b', result: { method: 'second' } })
ws.close()
})
it('sends multiple streaming responses via reply callback', async () => {
const { transport, port } = await createTransport((msg, reply) => {
const request = JSON.parse(msg)
reply(JSON.stringify({ id: request.id, ok: true, streaming: true, result: { chunk: 1 } }))
reply(JSON.stringify({ id: request.id, ok: true, streaming: true, result: { chunk: 2 } }))
reply(JSON.stringify({ id: request.id, ok: true, result: { type: 'end' } }))
})
await transport.start()
const ws = await connectWs(port)
const messages: string[] = []
await new Promise<void>((resolve) => {
ws.on('message', (data) => {
messages.push(typeof data === 'string' ? data : data.toString('utf-8'))
if (messages.length === 3) {
resolve()
}
})
ws.send(JSON.stringify({ id: 'stream-1', method: 'terminal.subscribe' }))
})
expect(JSON.parse(messages[0]!)).toMatchObject({ streaming: true, result: { chunk: 1 } })
expect(JSON.parse(messages[1]!)).toMatchObject({ streaming: true, result: { chunk: 2 } })
expect(JSON.parse(messages[2]!)).toMatchObject({ result: { type: 'end' } })
ws.close()
})
it('rejects oversized messages by closing the connection', async () => {
const { transport, port } = await createTransport()
await transport.start()
const ws = await connectWs(port)
// Why: ws maxPayload is 1MB — sending >1MB should trigger close.
const oversized = 'x'.repeat(1024 * 1024 + 100)
await new Promise<void>((resolve) => {
ws.once('close', () => resolve())
ws.send(oversized)
})
})
it('does not crash when replying to a closed connection', async () => {
let capturedReply: ((response: string) => void) | null = null
const { transport, port } = await createTransport((_msg, reply) => {
capturedReply = reply
})
await transport.start()
const ws = await connectWs(port)
ws.send(JSON.stringify({ id: 'req-1', method: 'test' }))
// Why: wait for the handler to capture the reply function.
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (capturedReply) {
clearInterval(interval)
resolve()
}
}, 10)
})
ws.close()
// Why: wait for the WebSocket to fully close before trying to reply.
await new Promise((resolve) => setTimeout(resolve, 50))
// Should not throw — guards with readyState check.
expect(() => capturedReply!(JSON.stringify({ id: 'req-1', ok: true }))).not.toThrow()
})
it('is idempotent on double start', async () => {
const { transport } = await createTransport()
await transport.start()
await transport.start()
await transport.stop()
})
it('is safe to stop without starting', async () => {
const { transport } = await createTransport()
await transport.stop()
})
})
+121
View File
@@ -0,0 +1,121 @@
// Why: the WebSocket transport enables mobile clients to connect to the Orca
// runtime over the local network. It uses wss:// with a self-signed TLS
// certificate to prevent passive sniffing, and per-device tokens (validated
// by the message handler in OrcaRuntimeRpcServer) instead of the shared
// runtime auth token.
import { createServer as createHttpsServer, type Server as HttpsServer } from 'https'
import { WebSocketServer, type WebSocket } from 'ws'
import type { RpcTransport } from './transport'
const MAX_WS_MESSAGE_BYTES = 1024 * 1024
const MAX_WS_CONNECTIONS = 32
export type WebSocketTransportOptions = {
host: string
port: number
tlsCert: string
tlsKey: string
}
export class WebSocketTransport implements RpcTransport {
private readonly host: string
private readonly port: number
private readonly tlsCert: string
private readonly tlsKey: string
private httpsServer: HttpsServer | null = null
private wss: WebSocketServer | null = null
private messageHandler: ((msg: string, reply: (response: string) => void) => void) | null = null
constructor({ host, port, tlsCert, tlsKey }: WebSocketTransportOptions) {
this.host = host
this.port = port
this.tlsCert = tlsCert
this.tlsKey = tlsKey
}
onMessage(handler: (msg: string, reply: (response: string) => void) => void): void {
this.messageHandler = handler
}
async start(): Promise<void> {
if (this.wss) {
return
}
const httpsServer = createHttpsServer({
cert: this.tlsCert,
key: this.tlsKey
})
const wss = new WebSocketServer({
server: httpsServer,
maxPayload: MAX_WS_MESSAGE_BYTES
})
wss.on('connection', (ws) => {
if (wss.clients.size > MAX_WS_CONNECTIONS) {
ws.close(1013, 'Maximum connections reached')
return
}
this.handleConnection(ws)
})
await new Promise<void>((resolve, reject) => {
httpsServer.once('error', reject)
httpsServer.listen(this.port, this.host, () => {
httpsServer.off('error', reject)
resolve()
})
})
this.httpsServer = httpsServer
this.wss = wss
}
async stop(): Promise<void> {
const wss = this.wss
const httpsServer = this.httpsServer
this.wss = null
this.httpsServer = null
if (wss) {
for (const client of wss.clients) {
client.close(1001, 'Server shutting down')
}
wss.close()
}
if (httpsServer) {
await new Promise<void>((resolve, reject) => {
httpsServer.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
}
// Why: WebSocket connections are long-lived (unlike Unix socket which is
// one-per-request). Multiple requests can be multiplexed on the same
// connection via the RPC `id` field. The transport delegates all auth
// and dispatch logic to the message handler set by OrcaRuntimeRpcServer.
private handleConnection(ws: WebSocket): void {
ws.on('message', (data) => {
const msg = typeof data === 'string' ? data : data.toString('utf-8')
this.messageHandler?.(msg, (response) => {
// Why: mobile clients disconnect frequently (backgrounding, network
// switch, phone locked). Guard writes to avoid errors on dead sockets.
if (ws.readyState === ws.OPEN) {
ws.send(response)
}
})
})
ws.on('error', () => {
ws.close()
})
}
}
+19 -13
View File
@@ -21,10 +21,12 @@ describe('runtime metadata', () => {
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_123',
pid: 42,
transport: {
kind: 'unix',
endpoint: '/tmp/orca.sock'
},
transports: [
{
kind: 'unix',
endpoint: '/tmp/orca.sock'
}
],
authToken: 'secret',
startedAt: 100
})
@@ -32,10 +34,12 @@ describe('runtime metadata', () => {
expect(readRuntimeMetadata(userDataPath)).toEqual({
runtimeId: 'rt_123',
pid: 42,
transport: {
kind: 'unix',
endpoint: '/tmp/orca.sock'
},
transports: [
{
kind: 'unix',
endpoint: '/tmp/orca.sock'
}
],
authToken: 'secret',
startedAt: 100
})
@@ -48,7 +52,7 @@ describe('runtime metadata', () => {
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_123',
pid: 42,
transport: null,
transports: [],
authToken: null,
startedAt: 100
})
@@ -68,10 +72,12 @@ describe('runtime metadata', () => {
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_123',
pid: 42,
transport: {
kind: 'unix',
endpoint: '/tmp/orca.sock'
},
transports: [
{
kind: 'unix',
endpoint: '/tmp/orca.sock'
}
],
authToken: 'secret',
startedAt: 100
})
+15 -15
View File
@@ -109,8 +109,8 @@ describe('OrcaRuntimeRpcServer', () => {
const metadata = readRuntimeMetadata(userDataPath)
expect(metadata?.runtimeId).toBe(runtime.getRuntimeId())
expect(metadata?.authToken).toBeTruthy()
expect(metadata?.transport?.endpoint).toBeTruthy()
expect(metadata?.transport).toEqual(server['transport'])
expect(metadata?.transports?.[0]?.endpoint).toBeTruthy()
expect(metadata?.transports).toEqual(server['transports'])
await server.stop()
expect(readRuntimeMetadata(userDataPath)).toMatchObject({
@@ -157,8 +157,8 @@ describe('OrcaRuntimeRpcServer', () => {
await expect(server.start()).rejects.toThrow('write failed')
expect(readRuntimeMetadata(userDataPath)).toBeNull()
expect(existsSync(endpoint)).toBe(false)
expect(server['transport']).toBeNull()
expect(server['server']).toBeNull()
expect(server['transports']).toEqual([])
expect(server['activeTransports']).toEqual([])
writeMetadataSpy.mockRestore()
})
@@ -171,7 +171,7 @@ describe('OrcaRuntimeRpcServer', () => {
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const response = await sendRequest(metadata!.transport!.endpoint, {
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_1',
authToken: metadata!.authToken,
method: 'status.get'
@@ -197,7 +197,7 @@ describe('OrcaRuntimeRpcServer', () => {
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const response = await sendRequest(metadata!.transport!.endpoint, {
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_1',
authToken: 'wrong',
method: 'status.get'
@@ -222,7 +222,7 @@ describe('OrcaRuntimeRpcServer', () => {
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const response = await sendRequest(metadata!.transport!.endpoint, {
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
authToken: metadata!.authToken,
method: 'status.get'
})
@@ -277,7 +277,7 @@ describe('OrcaRuntimeRpcServer', () => {
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const listResponse = await sendRequest(metadata!.transport!.endpoint, {
const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_list',
authToken: metadata!.authToken,
method: 'terminal.list',
@@ -301,7 +301,7 @@ describe('OrcaRuntimeRpcServer', () => {
).handle
expect(handle).toBeTruthy()
const showResponse = await sendRequest(metadata!.transport!.endpoint, {
const showResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_show',
authToken: metadata!.authToken,
method: 'terminal.show',
@@ -314,7 +314,7 @@ describe('OrcaRuntimeRpcServer', () => {
ok: true
})
const readResponse = await sendRequest(metadata!.transport!.endpoint, {
const readResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_read',
authToken: metadata!.authToken,
method: 'terminal.read',
@@ -327,7 +327,7 @@ describe('OrcaRuntimeRpcServer', () => {
ok: true
})
const sendResponse = await sendRequest(metadata!.transport!.endpoint, {
const sendResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_send',
authToken: metadata!.authToken,
method: 'terminal.send',
@@ -343,7 +343,7 @@ describe('OrcaRuntimeRpcServer', () => {
})
expect(writes).toEqual(['continue\r'])
const waitPromise = sendRequest(metadata!.transport!.endpoint, {
const waitPromise = sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_wait',
authToken: metadata!.authToken,
method: 'terminal.wait',
@@ -403,7 +403,7 @@ describe('OrcaRuntimeRpcServer', () => {
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const response = await sendRequest(metadata!.transport!.endpoint, {
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_ps',
authToken: metadata!.authToken,
method: 'worktree.ps'
@@ -444,7 +444,7 @@ describe('OrcaRuntimeRpcServer', () => {
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const response = await sendRequest(metadata!.transport!.endpoint, {
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_worktrees',
authToken: metadata!.authToken,
method: 'worktree.list',
@@ -474,7 +474,7 @@ describe('OrcaRuntimeRpcServer', () => {
const metadata = readRuntimeMetadata(userDataPath)
const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
const socket = createConnection(metadata!.transport!.endpoint)
const socket = createConnection(metadata!.transports[0]!.endpoint)
let buffer = ''
socket.setEncoding('utf8')
socket.once('error', reject)
+150 -101
View File
@@ -1,11 +1,9 @@
// Why: this is the single security boundary for the bundled CLI. It owns
// transport setup (unix socket / named pipe), auth-token enforcement, and
// bootstrap-metadata publication so a running runtime is always discoverable
// via exactly one on-disk file. Method handling lives in `rpc/` so this file
// stays easy to audit in one sitting.
// auth-token enforcement, bootstrap-metadata publication, and transport
// orchestration so a running runtime is always discoverable via exactly
// one on-disk file. Method handling lives in `rpc/` and transport specifics
// live in `rpc/unix-socket-transport.ts` and `rpc/ws-transport.ts`.
import { randomBytes } from 'crypto'
import { createServer, type Server, type Socket } from 'net'
import { chmodSync, existsSync, rmSync } from 'fs'
import { join } from 'path'
import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
import type { OrcaRuntimeService } from './orca-runtime'
@@ -13,122 +11,162 @@ import { writeRuntimeMetadata } from './runtime-metadata'
import { RpcDispatcher } from './rpc/dispatcher'
import type { RpcRequest, RpcResponse } from './rpc/core'
import { errorResponse } from './rpc/errors'
import type { RpcTransport } from './rpc/transport'
import { UnixSocketTransport } from './rpc/unix-socket-transport'
import { WebSocketTransport } from './rpc/ws-transport'
import { loadOrCreateTlsCertificate } from './tls-certificate'
import { DeviceRegistry } from './device-registry'
const DEFAULT_WS_PORT = 6768
type OrcaRuntimeRpcServerOptions = {
runtime: OrcaRuntimeService
userDataPath: string
pid?: number
platform?: NodeJS.Platform
enableWebSocket?: boolean
wsPort?: number
}
const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024
const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000
const MAX_RUNTIME_RPC_CONNECTIONS = 32
export class OrcaRuntimeRpcServer {
private readonly runtime: OrcaRuntimeService
private readonly dispatcher: RpcDispatcher
private readonly userDataPath: string
private readonly pid: number
private readonly platform: NodeJS.Platform
private readonly enableWebSocket: boolean
private readonly wsPort: number
private readonly authToken = randomBytes(24).toString('hex')
private server: Server | null = null
private transport: RuntimeTransportMetadata | null = null
private deviceRegistry: DeviceRegistry | null = null
private tlsFingerprint: string | null = null
private activeTransports: RpcTransport[] = []
private transports: RuntimeTransportMetadata[] = []
constructor({
runtime,
userDataPath,
pid = process.pid,
platform = process.platform
platform = process.platform,
enableWebSocket = false,
wsPort = DEFAULT_WS_PORT
}: OrcaRuntimeRpcServerOptions) {
this.runtime = runtime
this.dispatcher = new RpcDispatcher({ runtime })
this.userDataPath = userDataPath
this.pid = pid
this.platform = platform
this.enableWebSocket = enableWebSocket
this.wsPort = wsPort
}
getDeviceRegistry(): DeviceRegistry | null {
return this.deviceRegistry
}
getTlsFingerprint(): string | null {
return this.tlsFingerprint
}
getWebSocketEndpoint(): string | null {
const ws = this.transports.find((t) => t.kind === 'websocket')
return ws?.endpoint ?? null
}
async start(): Promise<void> {
if (this.server) {
if (this.activeTransports.length > 0) {
return
}
const transport = createRuntimeTransportMetadata(
const transportMeta = createRuntimeTransportMetadata(
this.userDataPath,
this.pid,
this.platform,
this.runtime.getRuntimeId()
)
if (transport.kind === 'unix' && existsSync(transport.endpoint)) {
rmSync(transport.endpoint, { force: true })
}
const server = createServer((socket) => {
this.handleConnection(socket)
const socketTransport = new UnixSocketTransport({
endpoint: transportMeta.endpoint,
kind: transportMeta.kind as 'unix' | 'named-pipe'
})
server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(transport.endpoint, () => {
server.off('error', reject)
resolve()
// Why: Unix socket transport uses the shared runtime auth token. This is
// the existing security model for CLI connections — the token lives in a
// 0o600-permissioned file on disk.
socketTransport.onMessage((msg, reply) => {
void this.handleMessage(msg).then((response) => {
reply(JSON.stringify(response))
})
})
if (transport.kind === 'unix') {
chmodSync(transport.endpoint, 0o600)
await socketTransport.start()
const activeTransports: RpcTransport[] = [socketTransport]
const transportsMeta: RuntimeTransportMetadata[] = [transportMeta]
// Why: WebSocket transport is opt-in and starts alongside the Unix socket.
// It uses TLS with a self-signed cert and per-device tokens from the
// device registry, not the shared runtime auth token.
if (this.enableWebSocket) {
try {
const tls = loadOrCreateTlsCertificate(this.userDataPath)
this.tlsFingerprint = tls.fingerprint
this.deviceRegistry = new DeviceRegistry(this.userDataPath)
const wsTransport = new WebSocketTransport({
host: '0.0.0.0',
port: this.wsPort,
tlsCert: tls.cert,
tlsKey: tls.key
})
// Why: WebSocket transport uses dispatchStreaming which can emit
// multiple responses for subscription methods. Auth validation
// happens before dispatch.
wsTransport.onMessage((msg, reply) => {
void this.handleWebSocketMessage(msg, reply)
})
await wsTransport.start()
activeTransports.push(wsTransport)
transportsMeta.push({
kind: 'websocket',
endpoint: `wss://0.0.0.0:${this.wsPort}`
})
} catch (error) {
// Why: WebSocket transport is supplementary — the runtime must still
// function if it fails to start (e.g., port in use). Log and continue
// with Unix socket only.
console.error('[runtime] Failed to start WebSocket transport:', error)
}
}
// Why: publish the transport into in-memory state before writing metadata
// so the bootstrap file always contains the real endpoint/token pair. The
// CLI only discovers the runtime through that file.
this.server = server
this.transport = transport
this.activeTransports = activeTransports
this.transports = transportsMeta
try {
this.writeMetadata()
} catch (error) {
// Why: a runtime that cannot publish bootstrap metadata is invisible to
// the `orca` CLI. Close the socket immediately instead of leaving behind
// a live but undiscoverable control plane.
this.server = null
this.transport = null
await new Promise<void>((resolve, reject) => {
server.close((closeError) => {
if (closeError) {
reject(closeError)
return
}
resolve()
})
}).catch(() => {})
if (transport.kind === 'unix' && existsSync(transport.endpoint)) {
rmSync(transport.endpoint, { force: true })
}
// the `orca` CLI. Close all transports immediately instead of leaving
// behind a live but undiscoverable control plane.
this.activeTransports = []
this.transports = []
await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {})
throw error
}
}
async stop(): Promise<void> {
const server = this.server
const transport = this.transport
this.server = null
this.transport = null
if (!server) {
const transports = this.activeTransports
this.activeTransports = []
this.transports = []
if (transports.length === 0) {
return
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
if (transport?.kind === 'unix' && existsSync(transport.endpoint)) {
rmSync(transport.endpoint, { force: true })
}
await Promise.all(transports.map((t) => t.stop()))
// Why: we intentionally leave the last metadata file behind instead of
// deleting it on shutdown. Shared userData paths can briefly host multiple
// Orca processes during restarts, updates, or development, and stale
@@ -136,44 +174,16 @@ export class OrcaRuntimeRpcServer {
// bootstrap file.
}
private handleConnection(socket: Socket): void {
let buffer = ''
socket.setEncoding('utf8')
socket.setNoDelay(true)
socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => {
socket.destroy()
})
socket.on('error', () => {
socket.destroy()
})
socket.on('data', (chunk: string) => {
buffer += chunk
// Why: the Orca runtime lives in Electron main, so it must reject
// oversized local RPC frames instead of letting a local client grow an
// unbounded buffer and stall the app.
if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) {
socket.write(
`${JSON.stringify(this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size'))}\n`
)
socket.end()
return
}
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1) {
const rawMessage = buffer.slice(0, newlineIndex).trim()
buffer = buffer.slice(newlineIndex + 1)
if (rawMessage) {
void this.handleMessage(rawMessage).then((response) => {
socket.write(`${JSON.stringify(response)}\n`)
})
}
newlineIndex = buffer.indexOf('\n')
}
})
}
// Why: Unix socket messages use one-shot dispatch (single response per
// request) and the shared runtime auth token from the 0o600 metadata file.
private async handleMessage(rawMessage: string): Promise<RpcResponse> {
// Why: empty messages are sent by the Unix socket transport layer when a
// client exceeds the max message size. The transport closes the connection
// after this response.
if (!rawMessage) {
return this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size')
}
let request: RpcRequest
try {
request = JSON.parse(rawMessage) as RpcRequest
@@ -197,6 +207,45 @@ export class OrcaRuntimeRpcServer {
return this.dispatcher.dispatch(request)
}
// Why: WebSocket messages go through streaming dispatch which can emit
// multiple responses. Auth uses per-device tokens from the device registry.
private async handleWebSocketMessage(
rawMessage: string,
reply: (response: string) => void
): Promise<void> {
let request: RpcRequest
try {
request = JSON.parse(rawMessage) as RpcRequest
} catch {
reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Invalid JSON request')))
return
}
if (typeof request.id !== 'string' || request.id.length === 0) {
reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Missing request id')))
return
}
if (typeof request.method !== 'string' || request.method.length === 0) {
reply(JSON.stringify(this.buildError(request.id, 'bad_request', 'Missing RPC method')))
return
}
const token =
typeof (request as Record<string, unknown>).deviceToken === 'string'
? ((request as Record<string, unknown>).deviceToken as string)
: null
if (!token) {
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token')))
return
}
if (!this.deviceRegistry?.validateToken(token)) {
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token')))
return
}
await this.dispatcher.dispatchStreaming(request, reply)
}
private buildError(id: string, code: string, message: string): RpcResponse {
return errorResponse(id, { runtimeId: this.runtime.getRuntimeId() }, code, message)
}
@@ -205,7 +254,7 @@ export class OrcaRuntimeRpcServer {
const metadata: RuntimeMetadata = {
runtimeId: this.runtime.getRuntimeId(),
pid: this.pid,
transport: this.transport,
transports: this.transports,
authToken: this.authToken,
startedAt: this.runtime.getStartedAt()
}
+201
View File
@@ -0,0 +1,201 @@
// Why: the WebSocket transport uses wss:// with a self-signed TLS certificate
// to prevent passive sniffing of device tokens on shared WiFi networks. The
// cert is generated once on first run and reused across restarts. The mobile
// app pins the certificate fingerprint received during QR pairing.
import { createHash, generateKeyPairSync, createSign, randomBytes } from 'crypto'
import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs'
import { join } from 'path'
const TLS_CERT_FILENAME = 'orca-tls-cert.pem'
const TLS_KEY_FILENAME = 'orca-tls-key.pem'
export type TlsCertificate = {
cert: string
key: string
fingerprint: string
}
export function loadOrCreateTlsCertificate(userDataPath: string): TlsCertificate {
const certPath = join(userDataPath, TLS_CERT_FILENAME)
const keyPath = join(userDataPath, TLS_KEY_FILENAME)
if (existsSync(certPath) && existsSync(keyPath)) {
const cert = readFileSync(certPath, 'utf-8')
const key = readFileSync(keyPath, 'utf-8')
return { cert, key, fingerprint: computeFingerprint(cert) }
}
const { cert, key } = generateSelfSignedCert()
writeFileSync(keyPath, key, { mode: 0o600 })
chmodSync(keyPath, 0o600)
writeFileSync(certPath, cert, { mode: 0o600 })
chmodSync(certPath, 0o600)
return { cert, key, fingerprint: computeFingerprint(cert) }
}
function computeFingerprint(certPem: string): string {
const derMatch = certPem.match(
/-----BEGIN CERTIFICATE-----\n([\s\S]+?)\n-----END CERTIFICATE-----/
)
if (!derMatch?.[1]) {
throw new Error('Invalid PEM certificate')
}
const der = Buffer.from(derMatch[1].replace(/\n/g, ''), 'base64')
const hash = createHash('sha256').update(der).digest('hex')
return `sha256:${hash}`
}
// Why: we generate a minimal self-signed X.509v1 certificate using only
// Node.js built-in crypto. This avoids a dependency on `selfsigned` or
// OpenSSL CLI and keeps the cert generation auditable in one place.
function generateSelfSignedCert(): { cert: string; key: string } {
const { publicKey, privateKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1'
})
const keyPem = privateKey.export({ type: 'sec1', format: 'pem' }) as string
const pubDer = publicKey.export({ type: 'spki', format: 'der' })
const serialNumber = randomBytes(16)
serialNumber[0]! &= 0x7f
const now = new Date()
const notBefore = formatAsn1Time(now)
// Why: 10 years validity — the cert is self-signed and pinned by fingerprint,
// not trusted via a CA chain, so long validity is fine.
const notAfter = formatAsn1Time(new Date(now.getFullYear() + 10, now.getMonth(), now.getDate()))
const issuerAndSubject = encodeDistinguishedName('Orca Runtime')
const tbs = encodeTbsCertificate({
serialNumber,
issuer: issuerAndSubject,
notBefore,
notAfter,
subject: issuerAndSubject,
subjectPublicKeyInfo: pubDer
})
const signer = createSign('SHA256')
signer.update(tbs)
const signature = signer.sign(privateKey)
const cert = encodeX509Certificate(tbs, signature)
const certPem = `-----BEGIN CERTIFICATE-----\n${cert
.toString('base64')
.match(/.{1,64}/g)!
.join('\n')}\n-----END CERTIFICATE-----\n`
return { cert: certPem, key: keyPem }
}
function formatAsn1Time(date: Date): string {
const y = date.getUTCFullYear().toString().slice(2)
const m = (date.getUTCMonth() + 1).toString().padStart(2, '0')
const d = date.getUTCDate().toString().padStart(2, '0')
return `${y}${m}${d}000000Z`
}
function encodeAsn1Length(length: number): Buffer {
if (length < 0x80) {
return Buffer.from([length])
}
if (length < 0x100) {
return Buffer.from([0x81, length])
}
return Buffer.from([0x82, (length >> 8) & 0xff, length & 0xff])
}
function encodeAsn1Sequence(...items: Buffer[]): Buffer {
const content = Buffer.concat(items)
return Buffer.concat([Buffer.from([0x30]), encodeAsn1Length(content.length), content])
}
function encodeAsn1Integer(value: Buffer): Buffer {
let buf = value
if (buf[0]! & 0x80) {
buf = Buffer.concat([Buffer.from([0x00]), buf])
}
return Buffer.concat([Buffer.from([0x02]), encodeAsn1Length(buf.length), buf])
}
function encodeAsn1Utf8String(str: string): Buffer {
const buf = Buffer.from(str, 'utf-8')
return Buffer.concat([Buffer.from([0x0c]), encodeAsn1Length(buf.length), buf])
}
function encodeAsn1UtcTime(timeStr: string): Buffer {
const buf = Buffer.from(timeStr, 'ascii')
return Buffer.concat([Buffer.from([0x17]), encodeAsn1Length(buf.length), buf])
}
function encodeAsn1BitString(data: Buffer): Buffer {
const content = Buffer.concat([Buffer.from([0x00]), data])
return Buffer.concat([Buffer.from([0x03]), encodeAsn1Length(content.length), content])
}
function encodeAsn1Oid(oid: number[]): Buffer {
const bytes: number[] = [oid[0]! * 40 + oid[1]!]
for (let i = 2; i < oid.length; i++) {
let value = oid[i]!
if (value < 128) {
bytes.push(value)
} else {
const encoded: number[] = []
encoded.push(value & 0x7f)
value >>= 7
while (value > 0) {
encoded.push((value & 0x7f) | 0x80)
value >>= 7
}
encoded.reverse()
bytes.push(...encoded)
}
}
const buf = Buffer.from(bytes)
return Buffer.concat([Buffer.from([0x06]), encodeAsn1Length(buf.length), buf])
}
function encodeDistinguishedName(commonName: string): Buffer {
// CN attribute type OID: 2.5.4.3
const cnOid = encodeAsn1Oid([2, 5, 4, 3])
const cnValue = encodeAsn1Utf8String(commonName)
const atv = encodeAsn1Sequence(cnOid, cnValue)
// RDN is a SET of attribute-type-value
const rdnContent = atv
const rdn = Buffer.concat([Buffer.from([0x31]), encodeAsn1Length(rdnContent.length), rdnContent])
return encodeAsn1Sequence(rdn)
}
function encodeTbsCertificate(params: {
serialNumber: Buffer
issuer: Buffer
notBefore: string
notAfter: string
subject: Buffer
subjectPublicKeyInfo: Buffer
}): Buffer {
// ecdsaWithSHA256: 1.2.840.10045.4.3.2
const signatureAlgorithm = encodeAsn1Sequence(encodeAsn1Oid([1, 2, 840, 10045, 4, 3, 2]))
const validity = encodeAsn1Sequence(
encodeAsn1UtcTime(params.notBefore),
encodeAsn1UtcTime(params.notAfter)
)
return encodeAsn1Sequence(
encodeAsn1Integer(params.serialNumber),
signatureAlgorithm,
params.issuer,
validity,
params.subject,
params.subjectPublicKeyInfo
)
}
function encodeX509Certificate(tbs: Buffer, signature: Buffer): Buffer {
const signatureAlgorithm = encodeAsn1Sequence(encodeAsn1Oid([1, 2, 840, 10045, 4, 3, 2]))
return encodeAsn1Sequence(tbs, signatureAlgorithm, encodeAsn1BitString(signature))
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { encodePairingOffer, decodePairingOffer, type PairingOffer } from './pairing'
describe('pairing offer', () => {
const offer: PairingOffer = {
v: 1,
endpoint: 'wss://192.168.1.10:6768',
deviceToken: 'abcdef1234567890abcdef1234567890abcdef1234567890',
certFingerprint: 'sha256:deadbeef'
}
it('encode then decode round-trips correctly', () => {
const url = encodePairingOffer(offer)
expect(url).toMatch(/^orca:\/\/pair#/)
const decoded = decodePairingOffer(url)
expect(decoded).toEqual(offer)
})
it('encoded URL uses base64url (no +, /, or = characters)', () => {
const url = encodePairingOffer(offer)
const fragment = url.split('#')[1]!
expect(fragment).not.toMatch(/[+/=]/)
})
it('rejects URLs with wrong scheme', () => {
expect(() => decodePairingOffer('https://example.com#abc')).toThrow('Invalid pairing URL')
})
it('rejects URLs without fragment', () => {
expect(() => decodePairingOffer('orca://pair')).toThrow('Invalid pairing URL')
})
it('rejects payloads with missing fields', () => {
const partial = { v: 1, endpoint: 'wss://host:1234' }
const base64 = Buffer.from(JSON.stringify(partial)).toString('base64')
expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow()
})
it('rejects payloads with wrong version', () => {
const wrong = { ...offer, v: 2 }
const base64 = Buffer.from(JSON.stringify(wrong)).toString('base64')
expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow()
})
it('rejects payloads with invalid fingerprint prefix', () => {
const wrong = { ...offer, certFingerprint: 'md5:abc' }
const base64 = Buffer.from(JSON.stringify(wrong)).toString('base64')
expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow()
})
})
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod'
export const PAIRING_OFFER_VERSION = 1
export const PairingOfferV1 = z.object({
v: z.literal(PAIRING_OFFER_VERSION),
endpoint: z.string().min(1),
deviceToken: z.string().min(1),
certFingerprint: z.string().startsWith('sha256:')
})
export type PairingOffer = z.infer<typeof PairingOfferV1>
export function encodePairingOffer(offer: PairingOffer): string {
const json = JSON.stringify(offer)
const base64url = Buffer.from(json, 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return `orca://pair#${base64url}`
}
export function decodePairingOffer(url: string): PairingOffer {
const hashIndex = url.indexOf('#')
if (!url.startsWith('orca://pair') || hashIndex === -1) {
throw new Error('Invalid pairing URL: must start with orca://pair#')
}
const base64url = url.slice(hashIndex + 1)
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const json = Buffer.from(base64, 'base64').toString('utf-8')
return PairingOfferV1.parse(JSON.parse(json))
}
+25 -1
View File
@@ -9,15 +9,39 @@ export type RuntimeTransportMetadata =
kind: 'named-pipe'
endpoint: string
}
| {
kind: 'websocket'
endpoint: string
}
export type RuntimeMetadata = {
runtimeId: string
pid: number
transport: RuntimeTransportMetadata | null
transports: RuntimeTransportMetadata[]
authToken: string | null
startedAt: number
}
// Why: the CLI must handle metadata files written by older Orca versions that
// used a singular `transport` field. This helper extracts the first transport
// matching the given kinds from either the new `transports` array or the
// legacy `transport` field.
export function findTransport(
metadata: RuntimeMetadata,
...kinds: RuntimeTransportMetadata['kind'][]
): RuntimeTransportMetadata | null {
const transports = metadata.transports
if (transports && Array.isArray(transports)) {
return transports.find((t) => kinds.includes(t.kind)) ?? null
}
// Why: backward compatibility with pre-transports-array metadata files.
const legacy = (metadata as Record<string, unknown>).transport as RuntimeTransportMetadata | null
if (legacy && kinds.includes(legacy.kind)) {
return legacy
}
return null
}
const PRIMARY_RUNTIME_METADATA_FILE = 'orca-runtime.json'
export function getRuntimeMetadataPath(userDataPath: string): string {