#!/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. Supports E2EE handshake. import { WebSocketServer, type WebSocket } from 'ws' import nacl from 'tweetnacl' import type { MobileGitStatusEntry } from '../src/source-control/mobile-git-status' const PORT = Number(process.env.PORT) || 6768 const AUTH_TOKEN = 'mock-device-token' // Why: generate a persistent server keypair for this mock session. // The public key is printed at startup so it can be used in pairing QR data. const serverKeyPair = nacl.box.keyPair() const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64') type E2EEState = { sharedKey: Uint8Array deviceToken: string | null authenticated: boolean } function deriveSharedKey(ourSecret: Uint8Array, peerPublic: Uint8Array): Uint8Array { return nacl.box.before(peerPublic, ourSecret) } function e2eeEncrypt(plaintext: string, sharedKey: Uint8Array): string { const nonce = nacl.randomBytes(nacl.box.nonceLength) const msg = new TextEncoder().encode(plaintext) const ciphertext = nacl.box.after(msg, nonce, sharedKey) const bundle = new Uint8Array(nonce.length + ciphertext.length) bundle.set(nonce) bundle.set(ciphertext, nonce.length) return Buffer.from(bundle).toString('base64') } function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null { const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) return null const nonce = bundle.slice(0, nacl.box.nonceLength) const ciphertext = bundle.slice(nacl.box.nonceLength) const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) if (!plaintext) return null return new TextDecoder().decode(plaintext) } 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 FakeGitEntry = MobileGitStatusEntry & { stagedFromUntracked?: boolean } let fakeGitEntries: FakeGitEntry[] = [ { path: 'src/auth/middleware.ts', status: 'modified', area: 'unstaged' }, { path: 'src/auth/jwt.ts', status: 'untracked', area: 'untracked' }, { path: 'README.md', status: 'modified', area: 'staged' } ] let fakeAhead = 1 let fakeBehind = 0 let fakeHasUpstream = true type RpcRequest = { id: string method: string deviceToken?: string params?: Record } type RpcResponse = { id: string ok: boolean result?: unknown error?: { code: string; message: string } streaming?: true _meta: { runtimeId: string } } function toGitStatusEntry(entry: FakeGitEntry): MobileGitStatusEntry { const { stagedFromUntracked: _stagedFromUntracked, ...statusEntry } = entry return statusEntry } function stageFakeGitEntry(entry: FakeGitEntry, filePaths: Set): FakeGitEntry { if (!filePaths.has(entry.path)) return entry if (entry.area === 'untracked') { return { ...entry, area: 'staged', status: 'added', stagedFromUntracked: true } } return { ...entry, area: 'staged' } } function unstageFakeGitEntry(entry: FakeGitEntry, filePaths: Set): FakeGitEntry { if (!filePaths.has(entry.path)) return entry if (entry.stagedFromUntracked) { return { ...entry, area: 'untracked', status: 'untracked', stagedFromUntracked: false } } return { ...entry, area: 'unstaged' } } 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, send: (response: RpcResponse) => void, ws: WebSocket ): void { switch (request.method) { case 'status.get': send( success(request.id, { runtimeId: 'mock-runtime', graphStatus: 'ready', windowCount: 1, tabCount: 2, terminalCount: 2 }) ) break case 'worktree.ps': send( success(request.id, { worktrees: FAKE_WORKTREES, totalCount: FAKE_WORKTREES.length, truncated: false }) ) break case 'terminal.list': send( success(request.id, { terminals: FAKE_TERMINALS, totalCount: FAKE_TERMINALS.length, truncated: false }) ) break case 'terminal.subscribe': { send(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) { send(success(request.id, { type: 'end' })) } return } send(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true)) chunkIndex++ }, 500) break } case 'terminal.send': send(success(request.id, { send: { handle: 'term-1', ok: true } })) break case 'terminal.unsubscribe': send(success(request.id, { unsubscribed: true })) break case 'git.status': send( success(request.id, { entries: fakeGitEntries.map(toGitStatusEntry), conflictOperation: 'unknown', branch: 'refs/heads/feature/auth-refactor', upstreamStatus: { hasUpstream: fakeHasUpstream, upstreamName: 'origin/feature/auth-refactor', ahead: fakeAhead, behind: fakeBehind } }) ) break case 'git.upstreamStatus': send( success(request.id, { hasUpstream: fakeHasUpstream, upstreamName: 'origin/feature/auth-refactor', ahead: fakeAhead, behind: fakeBehind }) ) break case 'git.stage': { const filePath = String(request.params?.filePath ?? '') fakeGitEntries = fakeGitEntries.map((entry) => stageFakeGitEntry(entry, new Set([filePath]))) send(success(request.id, { ok: true })) break } case 'git.bulkStage': { const filePaths = new Set((request.params?.filePaths as string[] | undefined) ?? []) fakeGitEntries = fakeGitEntries.map((entry) => stageFakeGitEntry(entry, filePaths)) send(success(request.id, { ok: true })) break } case 'git.unstage': { const filePath = String(request.params?.filePath ?? '') fakeGitEntries = fakeGitEntries.map((entry) => unstageFakeGitEntry(entry, new Set([filePath])) ) send(success(request.id, { ok: true })) break } case 'git.bulkUnstage': { const filePaths = new Set((request.params?.filePaths as string[] | undefined) ?? []) fakeGitEntries = fakeGitEntries.map((entry) => unstageFakeGitEntry(entry, filePaths)) send(success(request.id, { ok: true })) break } case 'git.discard': { const filePath = String(request.params?.filePath ?? '') fakeGitEntries = fakeGitEntries.filter((entry) => entry.path !== filePath) send(success(request.id, { ok: true })) break } case 'git.commit': fakeGitEntries = fakeGitEntries.filter((entry) => entry.area !== 'staged') fakeAhead += 1 send(success(request.id, { success: true })) break case 'git.fetch': send(success(request.id, { ok: true })) break case 'git.pull': fakeBehind = 0 send(success(request.id, { ok: true })) break case 'git.diff': send( success(request.id, { kind: 'text', originalContent: 'const status = "old"\\n', modifiedContent: 'const status = "new"\\n', originalIsBinary: false, modifiedIsBinary: false }) ) break case 'git.push': fakeHasUpstream = true fakeAhead = 0 send(success(request.id, { ok: true })) break case 'files.open': case 'files.openDiff': send( success(request.id, { worktree: request.params?.worktree ?? 'id:mock', relativePath: request.params?.relativePath ?? '', kind: 'text', opened: true }) ) break default: send(error(request.id, 'method_not_found', `Unknown method: ${request.method}`)) } } const wss = new WebSocketServer({ port: PORT }) // Why: each connection goes through an E2EE handshake before any RPC traffic. // The first message must be e2ee_hello (plaintext), then all subsequent // messages are encrypted with the derived shared key. const connectionState = new Map() wss.on('connection', (ws) => { console.log('[mock] Client connected — waiting for e2ee_hello') ws.on('message', (data) => { const msg = typeof data === 'string' ? data : data.toString('utf-8') const e2ee = connectionState.get(ws) if (!e2ee) { // Handshake phase — expect e2ee_hello let hello: { type?: string; publicKeyB64?: string } try { hello = JSON.parse(msg) } catch { ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid JSON' })) ws.close() return } if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64) { ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Expected e2ee_hello' })) ws.close() return } const clientPublicKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64')) if (clientPublicKey.length !== 32) { ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid public key' })) ws.close() return } const sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) connectionState.set(ws, { sharedKey, deviceToken: null, authenticated: false }) ws.send(JSON.stringify({ type: 'e2ee_ready' })) console.log('[mock] E2EE key exchange complete — waiting for encrypted auth') return } // Post-handshake — decrypt, handle, encrypt reply const plaintext = e2eeDecrypt(msg, e2ee.sharedKey) if (plaintext === null) { console.log('[mock] Decryption failed — dropping message') return } let request: RpcRequest try { request = JSON.parse(plaintext) as RpcRequest } catch { const encrypted = e2eeEncrypt( JSON.stringify(error('unknown', 'bad_request', 'Invalid JSON')), e2ee.sharedKey ) ws.send(encrypted) return } if (!e2ee.authenticated) { const auth = request as unknown as { type?: string; deviceToken?: string } if (auth.type !== 'e2ee_auth' || auth.deviceToken !== AUTH_TOKEN) { ws.send( e2eeEncrypt( JSON.stringify({ type: 'e2ee_error', error: { code: 'unauthorized' } }), e2ee.sharedKey ) ) ws.close() return } e2ee.deviceToken = auth.deviceToken e2ee.authenticated = true ws.send(e2eeEncrypt(JSON.stringify({ type: 'e2ee_authenticated' }), e2ee.sharedKey)) console.log('[mock] E2EE authentication complete') return } console.log(`[mock] ${request.method} (id: ${request.id})`) handleRequest( request, (response) => { if (ws.readyState === ws.OPEN) { ws.send(e2eeEncrypt(JSON.stringify(response), e2ee.sharedKey)) } }, ws ) }) ws.on('close', () => { connectionState.delete(ws) console.log('[mock] Client disconnected') }) ws.on('error', () => { connectionState.delete(ws) ws.close() }) }) console.log(`[mock] Orca mock server listening on ws://localhost:${PORT}`) console.log(`[mock] Auth token: ${AUTH_TOKEN}`) console.log(`[mock] Server public key (base64): ${serverPublicKeyB64}`) console.log(`[mock] E2EE enabled — clients must send e2ee_hello before RPC`)