orca/mobile/scripts/mock-server.ts

466 lines
14 KiB
TypeScript

#!/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<string, unknown>
}
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<string>): 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<string>): 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<WebSocket, E2EEState>()
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`)