Refactor runtime app architecture (#1878)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-14 23:13:37 -07:00
committed by GitHub
co-authored by Orca
parent 89827117e5
commit a22717bb35
308 changed files with 32140 additions and 4464 deletions
+3
View File
@@ -67,6 +67,9 @@ design-docs/
.context/
.atl/
# Machine-local agent hook endpoint files may contain auth tokens.
/agent-hooks/
# Local-only design/planning docs (not checked in)
docs/*.md
!docs/README*.md
+4
View File
@@ -39,6 +39,8 @@ module.exports = {
// integration — dependencies inside the asar archive are invisible to
// require(). Unpack CLI runtime deps so they resolve from
// app.asar.unpacked/node_modules/.
// Why: remote runtime connections use WebSocket + E2EE from the packaged CLI
// before the GUI process starts, so those deps need the same treatment.
// Why: sherpa-onnx native bindings (platform-specific subpackages) must be
// unpacked because they ship .node addons + .dylib/.so files that cannot be
// dlopen()'d from inside the asar archive.
@@ -49,6 +51,8 @@ module.exports = {
'out/main/computer-sidecar.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
'node_modules/tweetnacl/**',
'node_modules/zod/**',
'node_modules/sherpa-onnx*/**'
],
@@ -7,41 +7,41 @@ class AudioEngine {
private var engineConfigChangeObserver: Any?
private var sessionInterruptionObserver: Any?
private var mediaServicesResetObserver: Any?
public private(set) var voiceIOFormat: AVAudioFormat
public private(set) var isRecording = false
private var wasRecordingBeforeInterruption = false
public var onMicDataCallback: ((Data) -> Void)?
public var onInputVolumeCallback: ((Float) -> Void)?
public var onOutputVolumeCallback: ((Float) -> Void)?
public var onAudioInterruptionCallback: ((String) -> Void)?
private var inputLevelTimer: Timer?
private var outputLevelTimer: Timer?
private var inputBuffer = [Float](repeating: 0, count: 2048)
private var outputBuffer = [Float](repeating: 0, count: 2048)
private var inputBufferIndex = 0
private var outputBufferIndex = 0
private var hasFirstInputBeenDiscarded = false
private var discardRecording = false
private var discardFirstInputMillis = 2000
enum AudioEngineError: Error {
case audioFormatError
}
init() throws {
avAudioEngine.attach(speechPlayer)
guard let format = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1) else {
throw AudioEngineError.audioFormatError
}
voiceIOFormat = format
print("Voice IO format: \(String(describing: voiceIOFormat))")
engineConfigChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: avAudioEngine,
@@ -60,12 +60,12 @@ class AudioEngine {
queue: .main) { [weak self] _ in
self?.handleMediaServicesWereReset()
}
self.setupAudioSession()
self.setup()
self.start()
}
deinit {
if let observer = engineConfigChangeObserver {
NotificationCenter.default.removeObserver(observer)
@@ -77,29 +77,29 @@ class AudioEngine {
NotificationCenter.default.removeObserver(observer)
}
}
func setupAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
} catch {
print("Could not set the audio category: \(error.localizedDescription)")
}
do {
try session.setPreferredSampleRate(voiceIOFormat.sampleRate)
} catch {
print("Could not set the preferred sample rate: \(error.localizedDescription)")
}
do {
try session.setActive(true)
} catch {
print("Could not set the audio session as active")
}
}
func setup() {
let input = avAudioEngine.inputNode
do {
@@ -108,15 +108,15 @@ class AudioEngine {
print("Could not enable voice processing \(error)")
return
}
avAudioEngine.inputNode.isVoiceProcessingInputMuted = !isRecording
let output = avAudioEngine.outputNode
let mainMixer = avAudioEngine.mainMixerNode
avAudioEngine.connect(speechPlayer, to: mainMixer, format: voiceIOFormat)
avAudioEngine.connect(mainMixer, to: output, format: voiceIOFormat)
input.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
// We don't do any input processing (no volume calculation or passing mic data to the callback) if discardRecording == true
// See comment in the playPCMData function
@@ -125,48 +125,48 @@ class AudioEngine {
self?.updateInputVolume()
}
}
mainMixer.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
self?.processOutputBuffer(buffer)
self?.updateOutputVolume()
}
avAudioEngine.prepare()
}
func processMicrophoneBuffer(_ buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData?[0] else {
print("Error: Could not access channel data")
return
}
let frameCount = Int(buffer.frameLength)
var int16Samples = [Int16](repeating: 0, count: frameCount)
// Convert float samples to Int16 and update input buffer for volume calculation
for i in 0..<frameCount {
let floatSample = max(-1.0, min(1.0, channelData[i]))
int16Samples[i] = Int16(floatSample * Float(Int16.max))
inputBuffer[inputBufferIndex] = floatSample
inputBufferIndex = (inputBufferIndex + 1) % inputBuffer.count
}
// Create Data object from Int16 samples
let data = Data(bytes: int16Samples, count: frameCount * MemoryLayout<Int16>.size)
// Send the data to the callback
onMicDataCallback?(data)
}
func processOutputBuffer(_ buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData?[0] else {
print("Error: Could not access channel data")
return
}
let frameCount = Int(buffer.frameLength)
// Update output buffer for volume calculation
for i in 0..<frameCount {
let floatSample = max(-1.0, min(1.0, channelData[i]))
@@ -174,7 +174,7 @@ class AudioEngine {
outputBufferIndex = (outputBufferIndex + 1) % outputBuffer.count
}
}
func start() {
do {
try avAudioEngine.start()
@@ -182,7 +182,7 @@ class AudioEngine {
print("Could not start audio engine: \(error)")
}
}
func playPCMData(_ pcmData: Data) {
// Looks like we don't get a proper AEC for the very first chunks of audio that we play.
// To work around this, we will discard microphone input for the first few milliseconds.
@@ -196,33 +196,33 @@ class AudioEngine {
self.discardRecording = false
}
}
guard let buffer = createBuffer(from: pcmData) else {
print("Failed to create audio buffer")
return
}
speechPlayer.scheduleBuffer(buffer)
if !speechPlayer.isPlaying {
speechPlayer.play()
}
}
private func createBuffer(from data: Data) -> AVAudioPCMBuffer? {
let frameCount = UInt32(data.count) / 2 // 16-bit input = 2 bytes per frame
let format = AVAudioFormat(commonFormat: .pcmFormatFloat32,
sampleRate: 16000,
channels: 1,
interleaved: false)!
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
return nil
}
buffer.frameLength = frameCount
data.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) in
if let sourcePtr = rawBufferPointer.baseAddress?.assumingMemoryBound(to: Int16.self),
let destPtr = buffer.floatChannelData?[0] {
@@ -231,15 +231,15 @@ class AudioEngine {
}
}
}
return buffer
}
func bypassVoiceProcessing(_ bypass: Bool) {
let input = avAudioEngine.inputNode
input.isVoiceProcessingBypassed = bypass
}
func toggleRecording(_ val: Bool) -> Bool {
isRecording = val
if !isRecording {
@@ -251,10 +251,10 @@ class AudioEngine {
avAudioEngine.inputNode.isVoiceProcessingInputMuted = false
}
print("Recording \(isRecording ? "started" : "stopped")")
return isRecording
}
func stopRecordingAndPlayer(clearInterruptionResume: Bool = true){
if clearInterruptionResume {
wasRecordingBeforeInterruption = false
@@ -268,7 +268,7 @@ class AudioEngine {
speechPlayer.stop()
updateOutputVolume()
}
func resumeRecordingAndPlayer(){
do {
try AVAudioSession.sharedInstance().setActive(true)
@@ -279,12 +279,12 @@ class AudioEngine {
isRecording = toggleRecording(true)
speechPlayer.play()
}
func tearDown() {
stopRecordingAndPlayer()
avAudioEngine.stop()
}
var isPlaying: Bool {
return speechPlayer.isPlaying
}
@@ -308,13 +308,13 @@ class AudioEngine {
print("Playback resumed")
}
}
private func checkEngineIsRunning() {
if !avAudioEngine.isRunning {
start()
}
}
private func handleAudioSessionInterruption(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
@@ -343,39 +343,39 @@ class AudioEngine {
fatalError("Unknown type: \(type)")
}
}
private func handleMediaServicesWereReset() {
self.avAudioEngine.stop()
self.setup()
self.start()
}
private func updateInputVolume() {
let volume = calculateRMSLevel(from: inputBuffer)
onInputVolumeCallback?(volume)
}
private func updateOutputVolume() {
let volume = calculateRMSLevel(from: outputBuffer)
onOutputVolumeCallback?(volume)
}
private func calculateRMSLevel(from buffer: [Float]) -> Float {
let epsilon: Float = 1e-5 // To avoid log(0)
let rmsValue = sqrt(buffer.reduce(0) { $0 + $1 * $1 } / Float(buffer.count))
// Convert to decibels
let dbValue = 20 * log10(max(rmsValue, epsilon))
// Normalize decibel value to 0-1 range
// Assuming minimum audible is -60dB and maximum is 0dB
let minDb: Float = -80.0
let normalizedValue = max(0.0, min(1.0, (dbValue - minDb) / abs(minDb)))
// Optional: Apply exponential factor to push smaller values down
let expFactor: Float = 2.0 // Adjust this value to change the curve
let adjustedValue = pow(normalizedValue, expFactor)
return adjustedValue
}
}
+8 -1
View File
@@ -267,7 +267,14 @@ ws.on('open', () => {
})
ws.on('message', (data) => {
const plaintext = decrypt(data.toString())
let plaintext: string | null = null
try {
plaintext = decrypt(data.toString())
} catch {
// Plaintext handshake/control frames such as e2ee_ready are handled by the
// connect flow above. The global listener only cares about encrypted RPC.
return
}
if (!plaintext) return
const response = JSON.parse(plaintext) as RpcResponse
if (response._meta?.runtimeId) {
+5 -5
View File
@@ -1,20 +1,20 @@
// Why: declares the mobile's pairing protocol version and the minimum
// desktop version it can talk to. Duplicates the desktop's
// Why: declares the mobile client's runtime protocol version and the minimum
// server protocol it can talk to. Duplicates the desktop's
// `src/shared/protocol-version.ts` because Metro/Expo doesn't resolve
// outside `mobile/`. Manual sync is acceptable — these constants are
// expected to bump less than once a quarter.
//
// Bump MOBILE_PROTOCOL_VERSION when:
// - You change the meaning of an RPC mobile sends.
// - You stop relying on a desktop-side feature in a way old desktops
// - You stop relying on a server-side feature in a way old servers
// would notice.
// Do NOT bump for:
// - Adding new optional fields to outbound requests.
// - Reading new optional fields on incoming responses.
//
// Bump MIN_COMPATIBLE_DESKTOP_VERSION when mobile starts relying on a
// desktop feature added at a specific desktop protocol version. This
// triggers a hard-block screen for users paired to older desktops.
// server feature added at a specific runtime protocol version. This
// triggers a hard-block screen for users paired to older servers.
export const MOBILE_PROTOCOL_VERSION = 2
export const MIN_COMPATIBLE_DESKTOP_VERSION = 2
+1 -1
View File
@@ -88,8 +88,8 @@
"cmdk": "^1.1.1",
"dompurify": "^3.4.2",
"electron-updater": "^6.8.3",
"entities": "^6.0.1",
"emoji-picker-react": "^4.19.1",
"entities": "^6.0.1",
"github-slugger": "^2.0.0",
"hosted-git-info": "^9.0.3",
"html-to-image": "^1.11.13",
+2 -1
View File
@@ -14,7 +14,7 @@ export type CommandSpec = {
notes?: string[]
}
export const GLOBAL_FLAGS = ['help', 'json']
export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment']
export function parseArgs(argv: string[]): ParsedArgs {
const commandPath: string[] = []
@@ -93,6 +93,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
'storage',
'orchestration',
'computer',
'environment',
'note'
].includes(commandPath[0])) ||
(commandPath.length === 2 &&
+2
View File
@@ -14,6 +14,7 @@ import { BROWSER_ENV_HANDLERS } from './handlers/browser-env'
import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage'
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
import { COMPUTER_HANDLERS } from './handlers/computer'
import { ENVIRONMENT_HANDLERS } from './handlers/environment'
import { NOTE_HANDLERS } from './handlers/note'
export type HandlerContext = {
@@ -42,6 +43,7 @@ function buildHandlers(): Map<string, CommandHandler> {
BROWSER_STORAGE_HANDLERS,
ORCHESTRATION_HANDLERS,
COMPUTER_HANDLERS,
ENVIRONMENT_HANDLERS,
NOTE_HANDLERS
]
for (const group of groups) {
+28
View File
@@ -33,6 +33,7 @@ import type {
RuntimeWorktreePsResult,
RuntimeWorktreeRecord
} from '../shared/runtime-types'
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../shared/notes-types'
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
@@ -101,6 +102,33 @@ export function formatStatus(status: CliStatusResult): string {
return formatCliStatus(status)
}
export function formatEnvironmentList(result: {
environments: PublicKnownRuntimeEnvironment[]
}): string {
if (result.environments.length === 0) {
return 'No saved environments.'
}
return result.environments
.map(
(environment) =>
`${environment.id} ${environment.name} ${environment.endpoints[0]?.endpoint ?? 'no-endpoint'}`
)
.join('\n')
}
export function formatEnvironment(environment: PublicKnownRuntimeEnvironment): string {
return [
`id: ${environment.id}`,
`name: ${environment.name}`,
`runtimeId: ${environment.runtimeId ?? 'unknown'}`,
`lastUsedAt: ${environment.lastUsedAt ?? 'never'}`,
`preferredEndpointId: ${environment.preferredEndpointId}`,
...environment.endpoints.map(
(endpoint) => `endpoint: ${endpoint.id} ${endpoint.kind} ${endpoint.endpoint}`
)
].join('\n')
}
export function formatTerminalList(result: RuntimeTerminalListResult): string {
if (result.terminals.length === 0) {
return 'No live terminals.'
+27
View File
@@ -1,11 +1,38 @@
import type { CommandHandler } from '../dispatch'
import { formatCliStatus, formatStatus, printResult } from '../format'
import { RuntimeClientError, serveOrcaApp } from '../runtime-client'
export const CORE_HANDLERS: Record<string, CommandHandler> = {
open: async ({ client, json }) => {
const result = await client.openOrca()
printResult(result, json, formatCliStatus)
},
serve: async ({ flags, json }) => {
if (flags.get('no-pairing') === true && flags.get('mobile-pairing') === true) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --mobile-pairing or --no-pairing, not both.'
)
}
const rawPort = flags.get('port')
if (typeof rawPort === 'string') {
const port = Number(rawPort)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new RuntimeClientError('invalid_argument', `Invalid --port value: ${rawPort}`)
}
}
const exitCode = await serveOrcaApp({
json,
port: typeof rawPort === 'string' ? rawPort : null,
pairingAddress:
typeof flags.get('pairing-address') === 'string'
? (flags.get('pairing-address') as string)
: null,
noPairing: flags.get('no-pairing') === true,
mobilePairing: flags.get('mobile-pairing') === true
})
process.exitCode = exitCode
},
status: async ({ client, json }) => {
const result = await client.getCliStatus()
if (!json && !result.result.runtime.reachable) {
+75
View File
@@ -0,0 +1,75 @@
import type { CommandHandler } from '../dispatch'
import { formatEnvironment, formatEnvironmentList, printResult } from '../format'
import { getDefaultUserDataPath } from '../runtime-client'
import type { RuntimeRpcSuccess } from '../runtime-client'
import { RuntimeClientError } from '../runtime-client'
import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
import {
addEnvironmentFromPairingCode,
listEnvironments,
removeEnvironment,
resolveEnvironment,
type EnvironmentAddResult,
type EnvironmentRemoveResult
} from '../runtime/environments'
export const ENVIRONMENT_HANDLERS: Record<string, CommandHandler> = {
'environment add': async ({ flags, json }) => {
const name = getRequiredStringFlag(flags, 'name')
const pairingCode = getRequiredStringFlag(flags, 'pairing-code')
const environment = redactRuntimeEnvironment(
addEnvironmentFromPairingCode(getDefaultUserDataPath(), {
name,
pairingCode
})
)
printResult(
localSuccess({ environment }),
json,
(result: EnvironmentAddResult) =>
`Saved environment ${result.environment.name} (${result.environment.id}).`
)
},
'environment list': async ({ json }) => {
const environments = listEnvironments(getDefaultUserDataPath()).map(redactRuntimeEnvironment)
printResult(localSuccess({ environments }), json, formatEnvironmentList)
},
'environment show': async ({ flags, json }) => {
const selector = getRequiredStringFlag(flags, 'environment')
const environment = redactRuntimeEnvironment(
resolveEnvironment(getDefaultUserDataPath(), selector)
)
printResult(localSuccess({ environment }), json, ({ environment: value }) =>
formatEnvironment(value)
)
},
'environment rm': async ({ flags, json }) => {
const selector = getRequiredStringFlag(flags, 'environment')
const removed = redactRuntimeEnvironment(removeEnvironment(getDefaultUserDataPath(), selector))
printResult(
localSuccess({ removed }),
json,
(result: EnvironmentRemoveResult) =>
`Removed environment ${result.removed.name} (${result.removed.id}).`
)
}
}
function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
const value = flags.get(name)
if (typeof value !== 'string' || value.length === 0) {
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
}
return value
}
function localSuccess<TResult>(result: TResult): RuntimeRpcSuccess<TResult> {
return {
id: 'local',
ok: true,
result,
_meta: {
runtimeId: 'local'
}
}
}
+29 -2
View File
@@ -1,16 +1,43 @@
import { resolve as resolvePath } from 'path'
import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format'
import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags'
import { RuntimeClientError } from '../runtime-client'
function isAbsoluteServerPath(value: string): boolean {
return (
value.startsWith('/') ||
/^[A-Za-z]:[\\/]/.test(value) ||
value.startsWith('\\\\') ||
value.startsWith('//')
)
}
function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string {
if (!isRemote) {
return resolvePath(cwd, inputPath)
}
// Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
// Relative remote paths would silently target the wrong machine.
if (!isAbsoluteServerPath(inputPath)) {
throw new RuntimeClientError(
'invalid_argument',
'Remote repo add requires --path to be an absolute path on the remote server.'
)
}
return inputPath
}
export const REPO_HANDLERS: Record<string, CommandHandler> = {
'repo list': async ({ client, json }) => {
const result = await client.call<RuntimeRepoList>('repo.list')
printResult(result, json, formatRepoList)
},
'repo add': async ({ flags, client, json }) => {
'repo add': async ({ flags, client, cwd, json }) => {
const repoPath = getRequiredStringFlag(flags, 'path')
const result = await client.call<{ repo: Record<string, unknown> }>('repo.add', {
path: getRequiredStringFlag(flags, 'path')
path: resolveRepoAddPath(repoPath, cwd, client.isRemote)
})
printResult(result, json, formatRepoShow)
},
+6
View File
@@ -116,6 +116,12 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
printResult(result, json, formatTerminalRename)
},
'terminal create': async ({ flags, client, cwd, json }) => {
if (client.isRemote && !flags.has('worktree')) {
throw new RuntimeClientError(
'invalid_argument',
'Remote terminal create requires --worktree because the client cwd cannot identify a server worktree.'
)
}
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
command: getOptionalStringFlag(flags, 'command'),
+15
View File
@@ -8,8 +8,15 @@ Usage: orca <command> [options]
Startup:
open Launch Orca and wait for the runtime to be reachable
serve Start a headless Orca runtime server
status Show app/runtime/graph readiness
Environments:
environment add Save a remote Orca runtime from a pairing code
environment list List saved remote Orca runtimes
environment show Show one saved remote Orca runtime
environment rm Remove a saved remote Orca runtime
Repos:
repo list List repos registered in Orca
repo add Add a project to Orca by filesystem path
@@ -138,7 +145,12 @@ Browser Automation:
Common Commands:
orca open [--json]
orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--json]
orca status [--json]
orca environment add --name <name> --pairing-code <code> [--json]
orca environment list [--json]
orca environment show --environment <selector> [--json]
orca environment rm --environment <selector> [--json]
orca worktree list [--repo <selector>] [--limit <n>] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--activate] [--json]
orca worktree show --worktree <selector> [--json]
@@ -178,10 +190,13 @@ Wait Options:
Output Options:
--json Emit machine-readable JSON instead of human text
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair#... code
--environment <selector> Connect using a saved environment id or name
--help Show this help message
Behavior:
Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first.
Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.
Use selectors for discovery and handles for repeated live terminal operations.
Browser Workflow:
+367 -2
View File
@@ -2,13 +2,47 @@
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
const {
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn()
}))
vi.mock('./runtime-client', () => {
class RuntimeClient {
readonly isRemote: boolean
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
constructor(
_userDataPath?: string,
_requestTimeoutMs?: number,
remotePairingCode?: string | null,
environmentSelector?: string | null
) {
const effectivePairingCode =
remotePairingCode === undefined
? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING)
: remotePairingCode
const effectiveEnvironment =
environmentSelector === undefined ? process.env.ORCA_ENVIRONMENT : environmentSelector
if (effectivePairingCode && effectiveEnvironment) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --pairing-code or --environment, not both.'
)
}
this.isRemote = Boolean(effectivePairingCode || effectiveEnvironment)
}
}
class RuntimeClientError extends Error {
@@ -32,10 +66,19 @@ vi.mock('./runtime-client', () => {
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError
RuntimeRpcFailureError,
serveOrcaApp: serveOrcaAppMock,
getDefaultUserDataPath: getDefaultUserDataPathMock
}
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
import {
buildCurrentWorktreeSelector,
COMMAND_SPECS,
@@ -58,9 +101,36 @@ describe('COMMAND_SPECS collision check', () => {
describe('orca cli worktree awareness', () => {
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
const originalUserDataPath = process.env.ORCA_USER_DATA_PATH
const originalPairingCode = process.env.ORCA_PAIRING_CODE
const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING
const originalEnvironment = process.env.ORCA_ENVIRONMENT
beforeEach(() => {
callMock.mockReset()
serveOrcaAppMock.mockReset()
getDefaultUserDataPathMock.mockClear()
addEnvironmentFromPairingCodeMock.mockReset()
listEnvironmentsMock.mockReset()
addEnvironmentFromPairingCodeMock.mockReturnValue({
id: 'env-1',
name: 'desk',
createdAt: 100,
updatedAt: 100,
lastUsedAt: null,
runtimeId: null,
endpoints: [
{
id: 'ws-env-1',
kind: 'websocket',
label: 'WebSocket',
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: 'pk'
}
],
preferredEndpointId: 'ws-env-1'
})
listEnvironmentsMock.mockReturnValue([])
})
afterEach(() => {
@@ -75,6 +145,21 @@ describe('orca cli worktree awareness', () => {
} else {
process.env.ORCA_USER_DATA_PATH = originalUserDataPath
}
if (originalPairingCode === undefined) {
delete process.env.ORCA_PAIRING_CODE
} else {
process.env.ORCA_PAIRING_CODE = originalPairingCode
}
if (originalRemotePairing === undefined) {
delete process.env.ORCA_REMOTE_PAIRING
} else {
process.env.ORCA_REMOTE_PAIRING = originalRemotePairing
}
if (originalEnvironment === undefined) {
delete process.env.ORCA_ENVIRONMENT
} else {
process.env.ORCA_ENVIRONMENT = originalEnvironment
}
})
it('builds the current worktree selector from cwd', () => {
@@ -115,6 +200,25 @@ describe('orca cli worktree awareness', () => {
expect(logSpy).toHaveBeenCalledTimes(1)
})
it('rejects remote `worktree current` without listing worktrees from client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['worktree', 'current', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'current is a local cwd shortcut and cannot be resolved against a remote runtime.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('uses cwd when active is passed to worktree.set', async () => {
queueFixtures(
callMock,
@@ -171,6 +275,192 @@ describe('orca cli worktree awareness', () => {
})
})
it('starts a foreground headless server through `serve`', async () => {
serveOrcaAppMock.mockResolvedValue(0)
process.env.ORCA_ENVIRONMENT = 'stale-env'
await main(
['serve', '--json', '--port', '6768', '--pairing-address', '100.64.1.20', '--no-pairing'],
'/tmp/repo'
)
expect(serveOrcaAppMock).toHaveBeenCalledWith({
json: true,
port: '6768',
pairingAddress: '100.64.1.20',
noPairing: true,
mobilePairing: false
})
})
it('starts a foreground headless server with mobile pairing enabled', async () => {
serveOrcaAppMock.mockResolvedValue(0)
await main(
['serve', '--pairing-address', '100.64.1.20', '--mobile-pairing', '--json'],
'/tmp/repo'
)
expect(serveOrcaAppMock).toHaveBeenCalledWith({
json: true,
port: null,
pairingAddress: '100.64.1.20',
noPairing: false,
mobilePairing: true
})
})
it('rejects contradictory serve pairing flags', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['serve', '--mobile-pairing', '--no-pairing', '--json'], '/tmp/repo')
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Use either --mobile-pairing or --no-pairing, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects invalid serve ports before launching the app', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['serve', '--port', 'not-a-port', '--json'], '/tmp/repo')
expect(serveOrcaAppMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Invalid --port value: not-a-port'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('lists saved environments even when ORCA_ENVIRONMENT is set', async () => {
process.env.ORCA_ENVIRONMENT = 'stale-env'
listEnvironmentsMock.mockReturnValue([addEnvironmentFromPairingCodeMock()])
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['environment', 'list', '--json'], '/tmp/repo')
expect(listEnvironmentsMock).toHaveBeenCalledWith('/tmp/orca-user-data')
expect(callMock).not.toHaveBeenCalled()
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
})
it('adds saved environments even when ORCA_ENVIRONMENT is set', async () => {
process.env.ORCA_ENVIRONMENT = 'stale-env'
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['environment', 'add', '--name', 'desk', '--pairing-code', 'orca://pair#abc', '--json'],
'/tmp/repo'
)
expect(addEnvironmentFromPairingCodeMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
name: 'desk',
pairingCode: 'orca://pair#abc'
})
expect(callMock).not.toHaveBeenCalled()
expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
})
it('resolves repo.add paths against the invoking cli cwd', async () => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: path.resolve('/tmp/repo/apps/web'),
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['repo', 'add', '--path', './apps/web', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: path.resolve('/tmp/repo/apps/web')
})
})
it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['repo', 'add', '--path', './apps/web', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Remote repo add requires --path to be an absolute path on the remote server.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('sends remote repo.add absolute paths unchanged', async () => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: '/srv/orca/web',
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['repo', 'add', '--path', '/srv/orca/web', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: '/srv/orca/web'
})
})
it.each(['C:\\repo', 'C:/repo', '\\\\server\\share\\repo', '//server/share/repo'])(
'sends remote repo.add server absolute path %s unchanged',
async (serverPath) => {
queueFixtures(
callMock,
okFixture('req_repo_add', {
repo: {
id: 'repo-1',
path: serverPath,
displayName: 'web'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['repo', 'add', '--path', serverPath, '--pairing-code', 'remote-runtime', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('repo.add', {
path: serverPath
})
}
)
it('opts into setup and activation when worktree.create runs hooks', async () => {
queueFixtures(
callMock,
@@ -352,4 +642,79 @@ describe('orca cli worktree awareness', () => {
limit: undefined
})
})
it('rejects implicit remote terminal create instead of resolving from client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['terminal', 'create', '--pairing-code', 'remote-runtime', '--json'],
'/tmp/client/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Remote terminal create requires --worktree'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('sends explicit remote terminal create worktree selectors unchanged', async () => {
queueFixtures(
callMock,
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
worktreeId: 'repo-1::/srv/orca/feature',
title: 'Server terminal'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'terminal',
'create',
'--worktree',
'id:repo-1::/srv/orca/feature',
'--pairing-code',
'remote-runtime',
'--json'
],
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: undefined,
title: undefined,
focus: false
})
})
it('does not resolve implicit remote browser targets from client cwd', async () => {
queueFixtures(
callMock,
okFixture('req_tab_current', {
tab: {
browserPageId: 'page-1',
index: 0,
url: 'https://example.com',
title: 'Example',
active: true,
worktreeId: 'repo-1::/srv/orca/feature'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined })
})
})
+13 -1
View File
@@ -15,6 +15,10 @@ import { COMMAND_SPECS } from './specs'
export { COMMAND_SPECS } from './specs'
export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors'
function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
return commandPath[0] === 'environment' || commandPath[0] === 'serve'
}
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
const parsed = parseArgs(argv)
const helpPath = resolveHelpPath(parsed)
@@ -40,7 +44,15 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
// lookup so users do not get misleading "Orca is not running" failures for
// simple command typos or unsupported flags.
validateCommandAndFlags(COMMAND_SPECS, parsed)
const client = new RuntimeClient()
const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
const client = new RuntimeClient(
undefined,
undefined,
typeof pairingCode === 'string' ? pairingCode : undefined,
typeof environmentSelector === 'string' ? environmentSelector : undefined
)
await dispatch(parsed.commandPath, {
flags: parsed.flags,
client,
+1
View File
@@ -6,6 +6,7 @@ export {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError,
serveOrcaApp,
getDefaultUserDataPath,
type RuntimeRpcFailure,
type RuntimeRpcResponse,
+133 -4
View File
@@ -1,9 +1,17 @@
import type { CliStatusResult } from '../../shared/runtime-types'
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
import { launchOrcaApp } from './launch'
import { getDefaultUserDataPath, readMetadata } from './metadata'
import { getCliStatus } from './status'
import { sendRequest } from './transport'
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
import { sendWebSocketRequest } from './websocket-transport'
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat'
import {
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../shared/protocol-version'
// Why: for `orchestration.check --wait` the caller's method-level
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
@@ -16,13 +24,27 @@ const LONG_POLL_CLIENT_GRACE_MS = 10_000
export class RuntimeClient {
private readonly userDataPath: string
private readonly requestTimeoutMs: number
private readonly remotePairing: PairingOffer | null
private readonly environmentSelector: string | null
private remoteCompatChecked = false
// Why: browser commands trigger first-time session init (agent-browser connect +
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
// being so large that genuine hangs go unnoticed.
constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000) {
constructor(
userDataPath = getDefaultUserDataPath(),
requestTimeoutMs = 60_000,
remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null,
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null
) {
this.userDataPath = userDataPath
this.requestTimeoutMs = requestTimeoutMs
this.environmentSelector = environmentSelector
this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector)
}
get isRemote(): boolean {
return this.remotePairing !== null
}
async call<TResult>(
@@ -32,10 +54,30 @@ export class RuntimeClient {
timeoutMs?: number
}
): Promise<RuntimeRpcSuccess<TResult>> {
const metadata = readMetadata(this.userDataPath)
const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params)
if (this.remotePairing) {
if (method !== 'status.get') {
await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs)
}
const response = await sendWebSocketRequest<TResult>(
this.remotePairing,
method,
params,
effectiveTimeoutMs
)
if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
}
if (this.environmentSelector) {
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
runtimeId: response._meta.runtimeId
})
}
return response
}
const metadata = readMetadata(this.userDataPath)
const response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs)
if (!response.ok) {
if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
}
return response
@@ -58,9 +100,69 @@ export class RuntimeClient {
}
async getCliStatus(): Promise<RuntimeRpcSuccess<CliStatusResult>> {
if (this.remotePairing) {
const response = await this.call<RuntimeStatus>('status.get')
this.assertRemoteRuntimeStatusCompatible(response.result)
this.remoteCompatChecked = true
const graphState = response.result.graphStatus
return {
id: response.id,
ok: true,
result: {
app: {
running: true,
pid: null
},
runtime: {
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
reachable: true,
runtimeId: response.result.runtimeId
},
graph: {
state: graphState
}
},
_meta: response._meta
}
}
return getCliStatus(this.userDataPath)
}
private async ensureRemoteRuntimeCompatible(timeoutMs: number): Promise<void> {
if (!this.remotePairing || this.remoteCompatChecked) {
return
}
const response = await sendWebSocketRequest<RuntimeStatus>(
this.remotePairing,
'status.get',
undefined,
timeoutMs
)
if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
}
this.assertRemoteRuntimeStatusCompatible(response.result)
this.remoteCompatChecked = true
if (this.environmentSelector) {
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
runtimeId: response._meta.runtimeId
})
}
}
private assertRemoteRuntimeStatusCompatible(status: RuntimeStatus): void {
const verdict = evaluateRuntimeCompat({
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
serverMinCompatibleClientProtocolVersion:
status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
})
if (verdict.kind === 'blocked') {
throw new RuntimeClientError('incompatible_runtime', describeRuntimeCompatBlock(verdict))
}
}
async openOrca(timeoutMs = 15_000): Promise<RuntimeRpcSuccess<CliStatusResult>> {
const initial = await this.getCliStatus()
if (initial.result.runtime.reachable) {
@@ -84,6 +186,33 @@ export class RuntimeClient {
}
}
function resolveRemotePairing(
userDataPath: string,
pairingCode: string | null,
environmentSelector: string | null
): PairingOffer | null {
if (pairingCode && environmentSelector) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --pairing-code or --environment, not both.'
)
}
if (environmentSelector) {
return resolveEnvironmentPairingOffer(userDataPath, environmentSelector)
}
if (!pairingCode) {
return null
}
const pairing = parsePairingCode(pairingCode)
if (!pairing) {
throw new RuntimeClientError(
'invalid_argument',
'Invalid remote pairing code. Expected an orca://pair#... URL or bare pairing payload.'
)
}
return pairing
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+5 -62
View File
@@ -1,62 +1,5 @@
// Why: the Orca runtime is a separate process and may drift in version from
// the CLI (older CLI talking to newer app, or vice versa during dev HMR). A
// Zod schema at the decode boundary means a malformed frame surfaces as a
// single legible error instead of a silent mis-typed access downstream.
//
// The envelope shape mirrors src/main/runtime/rpc/core.ts. `result` is left
// unknown here — method-level types are checked by the caller via generics —
// so only the frame is validated, not the payload.
import { z } from 'zod'
const MetaSuccess = z.object({
runtimeId: z.string()
})
const MetaFailure = z
.object({
runtimeId: z.union([z.string(), z.null()])
})
.optional()
const Success = z.object({
id: z.string(),
ok: z.literal(true),
result: z.unknown(),
_meta: MetaSuccess
})
const Failure = z.object({
id: z.string(),
ok: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
data: z.unknown().optional()
}),
_meta: MetaFailure
})
// Why: transport-layer keepalive frame (server→client only). Not a terminal
// frame — the client reads past it and keeps waiting for the real
// success/failure. `id` and `_meta` are deliberately absent: keepalives carry
// no method-level semantics and aren't tied to a particular request (one
// connection handles one request today). See design doc §3.1.
const Keepalive = z.object({
_keepalive: z.literal(true)
})
// Why: switched from z.discriminatedUnion('ok', …) to z.union because
// keepalives have no `ok` field. Client code must branch on
// `'_keepalive' in frame` before treating the frame as Success/Failure.
export const RuntimeRpcEnvelopeSchema = z.union([Success, Failure, Keepalive])
export type RuntimeRpcKeepaliveFrame = z.infer<typeof Keepalive>
export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame {
return (
typeof frame === 'object' &&
frame !== null &&
'_keepalive' in frame &&
(frame as { _keepalive: unknown })._keepalive === true
)
}
export {
isKeepaliveFrame,
RuntimeRpcEnvelopeSchema,
type RuntimeRpcKeepaliveFrame
} from '../../shared/runtime-rpc-envelope'
+68
View File
@@ -0,0 +1,68 @@
import { mkdtempSync, statSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
import { encodePairingOffer } from '../../shared/pairing'
import {
addEnvironmentFromPairingCode,
getEnvironmentStorePath,
listEnvironments,
removeEnvironment,
resolveEnvironmentPairingOffer
} from './environments'
function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
return encodePairingOffer({
v: 2,
endpoint,
deviceToken: 'device-token',
publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64')
})
}
describe('CLI runtime environments', () => {
it('saves, resolves, and removes a paired environment', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-'))
const saved = addEnvironmentFromPairingCode(userDataPath, {
name: 'workstation',
pairingCode: pairingCode(),
now: 100
})
expect(listEnvironments(userDataPath)).toHaveLength(1)
expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation')).toMatchObject({
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'device-token'
})
expect(resolveEnvironmentPairingOffer(userDataPath, saved.id)).toMatchObject({
endpoint: 'ws://127.0.0.1:6768'
})
expect((statSync(getEnvironmentStorePath(userDataPath)).mode & 0o777).toString(8)).toBe('600')
const removed = removeEnvironment(userDataPath, 'workstation')
expect(removed.id).toBe(saved.id)
expect(listEnvironments(userDataPath)).toEqual([])
})
it('rejects an environment with the same name', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-'))
const first = addEnvironmentFromPairingCode(userDataPath, {
name: 'workstation',
pairingCode: pairingCode('ws://127.0.0.1:1111'),
now: 100
})
expect(() =>
addEnvironmentFromPairingCode(userDataPath, {
name: 'workstation',
pairingCode: pairingCode('ws://127.0.0.1:2222'),
now: 200
})
).toThrow('A server named "workstation" already exists.')
expect(listEnvironments(userDataPath)).toHaveLength(1)
expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation').endpoint).toBe(
'ws://127.0.0.1:1111'
)
expect(listEnvironments(userDataPath)[0]?.id).toBe(first.id)
})
})
+81
View File
@@ -0,0 +1,81 @@
import {
addEnvironmentFromPairingCode as addEnvironmentFromPairingCodeInStore,
getEnvironmentStorePath,
listEnvironments,
markEnvironmentUsed as markEnvironmentUsedInStore,
removeEnvironment as removeEnvironmentFromStore,
resolveEnvironment as resolveEnvironmentFromStore,
resolveEnvironmentPairingOffer as resolveEnvironmentPairingOfferFromStore,
RuntimeEnvironmentStoreError,
type RuntimeEnvironmentStoreErrorCode
} from '../../shared/runtime-environment-store'
import type {
KnownRuntimeEnvironment,
PublicKnownRuntimeEnvironment
} from '../../shared/runtime-environments'
import type { PairingOffer } from '../../shared/pairing'
import { RuntimeClientError } from './types'
export type EnvironmentAddResult = {
environment: PublicKnownRuntimeEnvironment
}
export type EnvironmentListResult = {
environments: PublicKnownRuntimeEnvironment[]
}
export type EnvironmentRemoveResult = {
removed: PublicKnownRuntimeEnvironment
}
export { getEnvironmentStorePath, listEnvironments }
export function addEnvironmentFromPairingCode(
userDataPath: string,
args: { name: string; pairingCode: string; now?: number }
): KnownRuntimeEnvironment {
return translateStoreError(() => addEnvironmentFromPairingCodeInStore(userDataPath, args))
}
export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment {
return translateStoreError(() => removeEnvironmentFromStore(userDataPath, selector))
}
export function resolveEnvironment(
userDataPath: string,
selector: string
): KnownRuntimeEnvironment {
return translateStoreError(() => resolveEnvironmentFromStore(userDataPath, selector))
}
export function resolveEnvironmentPairingOffer(
userDataPath: string,
selector: string
): PairingOffer {
return translateStoreError(() => resolveEnvironmentPairingOfferFromStore(userDataPath, selector))
}
export function markEnvironmentUsed(
userDataPath: string,
selector: string,
args: { runtimeId?: string | null; now?: number } = {}
): void {
translateStoreError(() => markEnvironmentUsedInStore(userDataPath, selector, args))
}
function translateStoreError<TResult>(fn: () => TResult): TResult {
try {
return fn()
} catch (error) {
if (error instanceof RuntimeEnvironmentStoreError) {
throw new RuntimeClientError(toRuntimeClientErrorCode(error.code), error.message)
}
throw error
}
}
function toRuntimeClientErrorCode(
code: RuntimeEnvironmentStoreErrorCode
): 'invalid_argument' | 'runtime_error' {
return code
}
+1
View File
@@ -1,4 +1,5 @@
export { RuntimeClient } from './client'
export { serveOrcaApp } from './launch'
export { getDefaultUserDataPath } from './metadata'
export {
RuntimeClientError,
+88
View File
@@ -0,0 +1,88 @@
import { resolve } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
}))
import { serveOrcaApp } from './launch'
describe('serveOrcaApp', () => {
beforeEach(() => {
spawnMock.mockReset()
process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca'
})
afterEach(() => {
delete process.env.ORCA_APP_EXECUTABLE
})
it('pins the Electron child cwd to the app root instead of the caller cwd', async () => {
const child = {
kill: vi.fn(),
once: vi.fn(
(event: string, handler: (code: number | null, signal: string | null) => void) => {
if (event === 'exit') {
queueMicrotask(() => handler(0, null))
}
return child
}
)
}
spawnMock.mockReturnValue(child)
await expect(serveOrcaApp({ json: true })).resolves.toBe(0)
expect(spawnMock).toHaveBeenCalledWith(
'/Applications/Orca.app/Contents/MacOS/Orca',
['--serve', '--serve-json'],
expect.objectContaining({
cwd: resolve(__dirname, '../../..')
})
)
})
it('passes mobile pairing through to the foreground server child', async () => {
const child = {
kill: vi.fn(),
once: vi.fn(
(event: string, handler: (code: number | null, signal: string | null) => void) => {
if (event === 'exit') {
queueMicrotask(() => handler(0, null))
}
return child
}
)
}
spawnMock.mockReturnValue(child)
await expect(
serveOrcaApp({
json: true,
port: '6768',
pairingAddress: '100.64.1.20',
mobilePairing: true
})
).resolves.toBe(0)
expect(spawnMock).toHaveBeenCalledWith(
'/Applications/Orca.app/Contents/MacOS/Orca',
[
'--serve',
'--serve-json',
'--serve-port',
'6768',
'--serve-pairing-address',
'100.64.1.20',
'--serve-mobile-pairing'
],
expect.objectContaining({
cwd: resolve(__dirname, '../../..')
})
)
})
})
+88 -1
View File
@@ -1,5 +1,5 @@
import { spawn as spawnProcess } from 'child_process'
import { dirname } from 'path'
import { dirname, resolve } from 'path'
import { RuntimeClientError } from './types'
export function launchOrcaApp(): void {
@@ -53,6 +53,93 @@ export function launchOrcaApp(): void {
)
}
export function serveOrcaApp(
args: {
json?: boolean
port?: string | null
pairingAddress?: string | null
noPairing?: boolean
mobilePairing?: boolean
} = {}
): Promise<number> {
const executable = resolveForegroundOrcaExecutable()
const childArgs = ['--serve']
if (args.json) {
childArgs.push('--serve-json')
}
if (args.port) {
childArgs.push('--serve-port', args.port)
}
if (args.pairingAddress) {
childArgs.push('--serve-pairing-address', args.pairingAddress)
}
if (args.noPairing) {
childArgs.push('--serve-no-pairing')
}
if (args.mobilePairing) {
childArgs.push('--serve-mobile-pairing')
}
const child = spawnProcess(executable, childArgs, {
cwd: resolveAppRoot(),
stdio: 'inherit',
env: stripElectronRunAsNode(process.env)
})
return new Promise((resolve, reject) => {
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
const forwardSignal = (signal: NodeJS.Signals): void => {
child.kill(signal)
forceKillTimer ??= setTimeout(() => {
child.kill('SIGKILL')
}, 5000)
}
const cleanup = (): void => {
process.off('SIGINT', forwardSignal)
process.off('SIGTERM', forwardSignal)
if (forceKillTimer) {
clearTimeout(forceKillTimer)
forceKillTimer = null
}
}
process.on('SIGINT', forwardSignal)
process.on('SIGTERM', forwardSignal)
child.once('error', (error) => {
cleanup()
reject(error)
})
child.once('exit', (code, signal) => {
cleanup()
if (typeof code === 'number') {
resolve(code)
return
}
reject(new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${signal}`))
})
})
}
function resolveAppRoot(): string {
// Why: dev-mode resource resolution in the Electron child may consult
// process.cwd(). Pin it to the app root so `orca serve` behaves the same
// regardless of the shell directory it was launched from.
return resolve(__dirname, '../../..')
}
function resolveForegroundOrcaExecutable(): string {
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
return overrideExecutable
}
if (process.env.ELECTRON_RUN_AS_NODE === '1') {
return process.execPath
}
throw new RuntimeClientError(
'runtime_serve_failed',
'Could not determine how to start Orca server. Set ORCA_APP_EXECUTABLE to the Orca executable.'
)
}
function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = { ...env }
delete next.ELECTRON_RUN_AS_NODE
+1 -1
View File
@@ -29,7 +29,7 @@ export async function getCliStatus(
try {
const response = await sendRequest<RuntimeStatus>(metadata, 'status.get', undefined, 1000)
if (!response.ok) {
if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
}
const graphState = response.result.graphStatus
+3 -3
View File
@@ -49,10 +49,10 @@ export async function sendRequest<TResult>(
settled = true
clearTimeout(timeout)
socket.end()
if (result.ok) {
resolve(result.response)
} else {
if (result.ok === false) {
reject(result.error)
} else {
resolve(result.response)
}
}
+6 -27
View File
@@ -1,31 +1,10 @@
// Why: the RPC envelope shape is the contract the CLI shares with the main
// runtime. Keeping the types and error classes in one leaf module lets every
// other runtime module depend on them without pulling in transport or launch
// code.
import type { RuntimeRpcFailure } from '../../shared/runtime-rpc-envelope'
export type RuntimeRpcSuccess<TResult> = {
id: string
ok: true
result: TResult
_meta: {
runtimeId: string
}
}
export type RuntimeRpcFailure = {
id: string
ok: false
error: {
code: string
message: string
data?: unknown
}
_meta?: {
runtimeId: string | null
}
}
export type RuntimeRpcResponse<TResult> = RuntimeRpcSuccess<TResult> | RuntimeRpcFailure
export type {
RuntimeRpcFailure,
RuntimeRpcResponse,
RuntimeRpcSuccess
} from '../../shared/runtime-rpc-envelope'
export class RuntimeClientError extends Error {
readonly code: string
+237
View File
@@ -0,0 +1,237 @@
import { createServer, type Server } from 'http'
import { mkdtempSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { WebSocketServer } from 'ws'
import { encodePairingOffer, type PairingOffer } from '../../shared/pairing'
import {
decrypt,
deriveSharedKey,
encrypt,
generateKeyPair,
publicKeyToBase64
} from '../../shared/e2ee-crypto'
import { RuntimeClient } from './client'
import { addEnvironmentFromPairingCode } from './environments'
import { RuntimeClientError } from './types'
import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../shared/protocol-version'
type TestRuntime = {
endpoint: string
publicKeyB64: string
deviceToken: string
close: () => Promise<void>
}
describe('CLI remote WebSocket transport', () => {
const servers: TestRuntime[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => server.close()))
})
it('calls a remote runtime through a mobile pairing offer', async () => {
const runtime = await startTestRuntime('runtime-ws-1')
servers.push(runtime)
const pairingUrl = encodePairingOffer({
v: 2,
endpoint: runtime.endpoint,
deviceToken: runtime.deviceToken,
publicKeyB64: runtime.publicKeyB64
})
const client = new RuntimeClient('/tmp/unused', 5_000, pairingUrl)
const response = await client.call<{ runtimeId: string }>('status.get')
expect(response.ok).toBe(true)
expect(response.result.runtimeId).toBe('runtime-ws-1')
})
it('rejects malformed remote pairing codes before local runtime lookup', () => {
expect(() => new RuntimeClient('/tmp/unused', 5_000, 'not-a-pairing-code')).toThrow(
RuntimeClientError
)
})
it('accepts a bare pairing payload as well as the orca URL wrapper', async () => {
const runtime = await startTestRuntime('runtime-ws-2')
servers.push(runtime)
const offer: PairingOffer = {
v: 2,
endpoint: runtime.endpoint,
deviceToken: runtime.deviceToken,
publicKeyB64: runtime.publicKeyB64
}
const barePayload = encodePairingOffer(offer).split('#')[1]!
const client = new RuntimeClient('/tmp/unused', 5_000, barePayload)
const status = await client.getCliStatus()
expect(status.result.runtime.reachable).toBe(true)
expect(status.result.runtime.runtimeId).toBe('runtime-ws-2')
})
it('connects through a saved environment selector', async () => {
const runtime = await startTestRuntime('runtime-env-1')
servers.push(runtime)
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-env-'))
addEnvironmentFromPairingCode(userDataPath, {
name: 'remote-dev',
pairingCode: encodePairingOffer({
v: 2,
endpoint: runtime.endpoint,
deviceToken: runtime.deviceToken,
publicKeyB64: runtime.publicKeyB64
})
})
const client = new RuntimeClient(userDataPath, 5_000, null, 'remote-dev')
const status = await client.getCliStatus()
expect(status.result.runtime.reachable).toBe(true)
expect(status.result.runtime.runtimeId).toBe('runtime-env-1')
})
it('blocks remote RPCs when the server protocol is too old', async () => {
const runtime = await startTestRuntime('runtime-old', { runtimeProtocolVersion: 1 })
servers.push(runtime)
const client = new RuntimeClient(
'/tmp/unused',
5_000,
encodePairingOffer({
v: 2,
endpoint: runtime.endpoint,
deviceToken: runtime.deviceToken,
publicKeyB64: runtime.publicKeyB64
})
)
await expect(client.call('repo.list')).rejects.toMatchObject({
code: 'incompatible_runtime',
message: expect.stringContaining('server is too old')
})
})
})
async function startTestRuntime(
runtimeId: string,
statusOverrides: {
runtimeProtocolVersion?: number
minCompatibleRuntimeClientVersion?: number
} = {}
): Promise<TestRuntime> {
const serverKeyPair = generateKeyPair()
const deviceToken = `token-${runtimeId}`
const httpServer = createServer()
const wss = new WebSocketServer({ server: httpServer })
wss.on('connection', (ws) => {
let sharedKey: Uint8Array | null = null
let authenticated = false
ws.on('message', (data) => {
const frame = data.toString()
if (!sharedKey) {
const hello = JSON.parse(frame) as { type?: string; publicKeyB64?: string }
const clientPublicKey = Buffer.from(hello.publicKeyB64 ?? '', 'base64')
sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey)
ws.send(JSON.stringify({ type: 'e2ee_ready' }))
return
}
const plaintext = decrypt(frame, sharedKey)
if (!plaintext) {
ws.close(4003, 'decrypt failed')
return
}
if (!authenticated) {
const auth = JSON.parse(plaintext) as { type?: string; deviceToken?: string }
if (auth.type !== 'e2ee_auth' || auth.deviceToken !== deviceToken) {
ws.send(encrypt(JSON.stringify({ type: 'e2ee_error' }), sharedKey))
ws.close(4001, 'auth failed')
return
}
authenticated = true
ws.send(encrypt(JSON.stringify({ type: 'e2ee_authenticated' }), sharedKey))
return
}
const request = JSON.parse(plaintext) as { id: string; method: string }
const response =
request.method === 'status.get'
? {
id: request.id,
ok: true,
result: {
runtimeId,
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion:
statusOverrides.runtimeProtocolVersion ?? RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion:
statusOverrides.minCompatibleRuntimeClientVersion ??
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
},
_meta: { runtimeId }
}
: {
id: request.id,
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId }
}
ws.send(encrypt(JSON.stringify(response), sharedKey))
})
})
await listen(httpServer)
const address = httpServer.address()
if (!address || typeof address === 'string') {
throw new Error('Expected TCP test server')
}
return {
endpoint: `ws://127.0.0.1:${address.port}`,
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey),
deviceToken,
close: async () => {
await new Promise<void>((resolve) => {
wss.close(() => resolve())
for (const client of wss.clients) {
client.close()
}
})
await closeHttpServer(httpServer)
}
}
}
async function listen(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
}
async function closeHttpServer(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
+22
View File
@@ -0,0 +1,22 @@
import type { PairingOffer } from '../../shared/pairing'
import {
RemoteRuntimeClientError,
sendRemoteRuntimeRequest
} from '../../shared/remote-runtime-client'
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
export async function sendWebSocketRequest<TResult>(
pairing: PairingOffer,
method: string,
params: unknown,
timeoutMs: number
): Promise<RuntimeRpcResponse<TResult>> {
try {
return await sendRemoteRuntimeRequest<TResult>(pairing, method, params, timeoutMs)
} catch (error) {
if (error instanceof RemoteRuntimeClientError) {
throw new RuntimeClientError(error.code, error.message)
}
throw error
}
}
+27 -2
View File
@@ -1,5 +1,6 @@
import { isAbsolute, relative, resolve as resolvePath } from 'path'
import type { ComputerAppQuery, RuntimeWorktreeListResult } from '../shared/runtime-types'
import { isPathInsideOrEqual } from '../shared/cross-platform-path'
import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime-client'
import { getOptionalStringFlag, getRequiredStringFlag } from './flags'
@@ -26,7 +27,22 @@ export function normalizeWorktreeSelector(selector: string, cwd: string): string
return selector
}
function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient): void {
if (!client.isRemote) {
return
}
// Why: a paired CLI's cwd belongs to the client machine, not the runtime
// server, so cwd-derived worktree selectors are only valid locally.
throw new RuntimeClientError(
'invalid_argument',
`${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:<id>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
)
}
function isWithinPath(parentPath: string, childPath: string): boolean {
if (isPathInsideOrEqual(parentPath, childPath)) {
return true
}
const relativePath = relative(parentPath, childPath)
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
}
@@ -35,6 +51,8 @@ export async function resolveCurrentWorktreeSelector(
cwd: string,
client: RuntimeClient
): Promise<string> {
assertLocalCwdWorktreeSelector('current', client)
const currentPath = resolvePath(cwd)
const worktrees = await client.call<RuntimeWorktreeListResult>('worktree.list', {
limit: 10_000
@@ -68,6 +86,7 @@ export async function getOptionalWorktreeSelector(
return undefined
}
if (value === 'active' || value === 'current') {
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
@@ -81,13 +100,14 @@ export async function getRequiredWorktreeSelector(
): Promise<string> {
const value = getRequiredStringFlag(flags, name)
if (value === 'active' || value === 'current') {
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
}
// Why: browser commands default to the current worktree (auto-resolve from cwd).
// --worktree all bypasses filtering. Omitting --worktree auto-resolves.
// Why: local browser commands default to the current worktree by auto-resolving
// from cwd. Remote commands omit worktree so the runtime uses server-side focus.
export async function getBrowserWorktreeSelector(
flags: Map<string, string | boolean>,
cwd: string,
@@ -99,10 +119,14 @@ export async function getBrowserWorktreeSelector(
}
if (value) {
if (value === 'active' || value === 'current') {
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
}
if (client.isRemote) {
return undefined
}
// Default: auto-resolve from cwd
try {
return await resolveCurrentWorktreeSelector(cwd, client)
@@ -146,6 +170,7 @@ export async function getBrowserCommandTarget(
return { page }
}
if (explicitWorktree === 'active' || explicitWorktree === 'current') {
assertLocalCwdWorktreeSelector(explicitWorktree, client)
return {
page,
worktree: await resolveCurrentWorktreeSelector(cwd, client)
+18
View File
@@ -9,6 +9,24 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca open', 'orca open --json']
},
{
path: ['serve'],
summary: 'Start an Orca runtime server without opening a desktop window',
usage:
'orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'port', 'pairing-address', 'mobile-pairing', 'no-pairing'],
notes: [
'Runs in the foreground and prints the runtime endpoint. Stop it with Ctrl+C.',
'Use --pairing-address when clients should connect through a LAN, Tailscale, SSH-forward, or public tunnel address.',
'Use --mobile-pairing to print a mobile-scoped pairing QR/link instead of the default runtime-environment pairing link.'
],
examples: [
'orca serve',
'orca serve --json',
'orca serve --port 6768 --pairing-address 100.64.1.20',
'orca serve --pairing-address 100.64.1.20 --mobile-pairing'
]
},
{
path: ['status'],
summary: 'Show app/runtime/graph readiness',
+30
View File
@@ -0,0 +1,30 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [
{
path: ['environment', 'add'],
summary: 'Save a remote Orca runtime environment from a pairing code',
usage: 'orca environment add --name <name> --pairing-code <code> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'name'],
examples: ['orca environment add --name work-laptop --pairing-code orca://pair#...']
},
{
path: ['environment', 'list'],
summary: 'List saved Orca runtime environments',
usage: 'orca environment list [--json]',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['environment', 'show'],
summary: 'Show one saved Orca runtime environment',
usage: 'orca environment show --environment <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['environment', 'rm'],
summary: 'Remove one saved Orca runtime environment',
usage: 'orca environment rm --environment <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS]
}
]
+2
View File
@@ -4,6 +4,7 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
import { CORE_COMMAND_SPECS } from './core'
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
import { COMPUTER_COMMAND_SPECS } from './computer'
import { ENVIRONMENT_COMMAND_SPECS } from './environment'
import { NOTE_COMMAND_SPECS } from './note'
export const COMMAND_SPECS: CommandSpec[] = [
@@ -12,5 +13,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
...BROWSER_ADVANCED_COMMAND_SPECS,
...ORCHESTRATION_COMMAND_SPECS,
...COMPUTER_COMMAND_SPECS,
...ENVIRONMENT_COMMAND_SPECS,
...NOTE_COMMAND_SPECS
]
+19
View File
@@ -0,0 +1,19 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const builderConfig = require('../../../config/electron-builder.config.cjs') as {
asarUnpack?: string[]
}
describe('packaged CLI assets', () => {
it('unpacks runtime dependencies used before Electron asar integration is available', () => {
expect(builderConfig.asarUnpack).toEqual(
expect.arrayContaining([
'node_modules/ws/**',
'node_modules/tweetnacl/**',
'node_modules/zod/**'
])
)
})
})
+8 -4
View File
@@ -220,10 +220,14 @@ describe('getDiff', () => {
const result = await getDiff('/repo', 'src/file.ts', false)
expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith(2, ['show', 'HEAD:src/file.ts'], {
cwd: '/repo',
maxBuffer: 10 * 1024 * 1024
})
expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith(
2,
['show', '--end-of-options', 'HEAD:src/file.ts'],
{
cwd: '/repo',
maxBuffer: 10 * 1024 * 1024
}
)
expect(result.originalContent).toBe('head-content\n')
expect(result.modifiedContent).toBe('working-tree-content')
})
+8 -5
View File
@@ -551,7 +551,7 @@ async function resolveCompareRef(worktreePath: string): Promise<string> {
}
async function resolveRefOid(worktreePath: string, ref: string): Promise<string> {
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], {
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', '--end-of-options', ref], {
cwd: worktreePath
})
return stdout.trim()
@@ -613,10 +613,13 @@ async function readGitBlobAtOidPath(
filePath: string
): Promise<GitBlobReadResult> {
try {
const { stdout } = await gitExecFileAsyncBuffer(['show', `${oid}:${filePath}`], {
cwd: worktreePath,
maxBuffer: MAX_GIT_SHOW_BYTES
})
const { stdout } = await gitExecFileAsyncBuffer(
['show', '--end-of-options', `${oid}:${filePath}`],
{
cwd: worktreePath,
maxBuffer: MAX_GIT_SHOW_BYTES
}
)
return { ...bufferToBlob(stdout, filePath), exists: true }
} catch {
+174 -28
View File
@@ -5,6 +5,7 @@
import { grantDirAcl } from './win32-utils'
import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron'
import { electronApp, is } from '@electron-toolkit/utils'
import * as QRCode from 'qrcode'
import devIcon from '../../resources/icon-dev.png?asset'
import { Store, initDataPath } from './persistence'
import { StatsCollector, initStatsPath } from './stats/collector'
@@ -51,7 +52,12 @@ import { codexHookService } from './codex/hook-service'
import { geminiHookService } from './gemini/hook-service'
import { cursorHookService } from './cursor/hook-service'
import { droidHookService } from './droid/hook-service'
import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvider } from './ipc/pty'
import {
getPtyIdForPaneKey,
registerPaneKeyTeardownListener,
getLocalPtyProvider,
registerHeadlessPtyRuntime
} from './ipc/pty'
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
import { browserManager } from './browser/browser-manager'
import { setUnreadDockBadgeCount } from './dock/unread-badge'
@@ -79,6 +85,7 @@ let disposeFeatureWallFirstAgentTour: (() => void) | null = null
let watcherShutdownPromise: Promise<void> | null = null
let watcherShutdownDone = false
let automations: AutomationService | null = null
const isServeMode = process.argv.includes('--serve')
installUncaughtPipeErrorGuard()
// Why: propagate the Orca app version into `process.env` so PTY-env
@@ -141,7 +148,8 @@ function focusExistingWindow(): void {
// agent work, so that routing ambiguity is acceptable. Packaged Orca keeps
// the lock to protect against the corruption documented in PR #1326 /
// issue #1312.
const hasSingleInstanceLock = is.dev ? true : acquireSingleInstanceLock(app, focusExistingWindow)
const hasSingleInstanceLock =
is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow)
if (!hasSingleInstanceLock) {
if (is.dev) {
// Why: packaged runs have no attached console, but dev runs do. Emit a
@@ -160,8 +168,12 @@ if (!hasSingleInstanceLock) {
// below happen — those handlers only fire after whenReady, which app.quit()
// prevents from ever dispatching.
if (hasSingleInstanceLock) {
installDevParentDisconnectQuit(is.dev)
installDevParentWatchdog(is.dev)
// Why: dev parent shutdown coupling is only for electron-vite desktop runs.
// `orca serve` may be launched through a CLI shim or background shell whose
// parent lifetime is not the intended server lifetime.
const shouldCoupleToDevParent = is.dev && !isServeMode
installDevParentDisconnectQuit(shouldCoupleToDevParent)
installDevParentWatchdog(shouldCoupleToDevParent)
// Why: must run after configureDevUserDataPath (which redirects userData to
// orca-dev in dev mode) but before app.setName('Orca') inside whenReady
// (which would change the resolved path on case-sensitive filesystems).
@@ -392,6 +404,108 @@ const syntheticTitleSpinnerByPaneKey = new Map<
{ timer: ReturnType<typeof setInterval>; frame: number; profile: SyntheticTitleProfile }
>()
type ServeOptions = {
json: boolean
wsPort?: number
pairingAddress: string | null
noPairing: boolean
mobilePairing: boolean
}
function getServeOptions(argv = process.argv): ServeOptions {
const valueAfter = (flag: string): string | null => {
const index = argv.indexOf(flag)
if (index === -1) {
return null
}
const value = argv[index + 1]
return value && !value.startsWith('--') ? value : null
}
const rawPort = valueAfter('--serve-port')
let wsPort: number | undefined
if (rawPort) {
const parsedPort = Number(rawPort)
if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) {
throw new Error(`Invalid --serve-port value: ${rawPort}`)
}
wsPort = parsedPort
}
return {
json: argv.includes('--serve-json'),
...(wsPort !== undefined ? { wsPort } : {}),
pairingAddress: valueAfter('--serve-pairing-address'),
noPairing: argv.includes('--serve-no-pairing'),
mobilePairing: argv.includes('--serve-mobile-pairing')
}
}
async function renderTerminalPairingQr(pairingUrl: string): Promise<string | null> {
try {
return await QRCode.toString(pairingUrl, { type: 'terminal', small: true })
} catch {
try {
return await QRCode.toString(pairingUrl, { type: 'utf8' })
} catch {
return null
}
}
}
async function printServeReady(options: ServeOptions): Promise<void> {
if (!runtime || !runtimeRpc) {
throw new Error('Runtime server must be initialized before printing serve readiness')
}
const endpoint = runtimeRpc.getWebSocketEndpoint()
const pairing = options.noPairing
? ({ available: false } as const)
: runtimeRpc.createPairingOffer({
address: options.pairingAddress,
name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`,
scope: options.mobilePairing ? 'mobile' : 'runtime'
})
const pairingQr =
pairing.available && options.mobilePairing
? await renderTerminalPairingQr(pairing.pairingUrl)
: null
if (options.json) {
console.log(
JSON.stringify({
type: 'orca_server_ready',
runtimeId: runtime.getRuntimeId(),
endpoint,
pairing: pairing.available
? {
url: pairing.pairingUrl,
endpoint: pairing.endpoint,
deviceId: pairing.deviceId,
scope: options.mobilePairing ? 'mobile' : 'runtime',
qr: pairingQr
}
: null
})
)
return
}
console.log(`Orca server ready: ${endpoint ?? 'websocket unavailable'}`)
if (pairing.available) {
if (options.mobilePairing && pairingQr) {
console.log(`Mobile pairing QR:\n${pairingQr}`)
}
console.log(`Pairing URL: ${pairing.pairingUrl}`)
}
}
function installServeSignalHandlers(): void {
const quit = (): void => {
// Why: foreground `orca serve` is controlled by the parent CLI/terminal,
// so POSIX termination signals should follow Electron's normal quit path
// and flush runtime metadata, daemon checkpoints, and telemetry.
app.quit()
}
process.once('SIGINT', quit)
process.once('SIGTERM', quit)
}
// Why: on PTY teardown the paneKey→ptyId mapping is dropped, so the spinner
// interval would keep firing but sendSyntheticTitle would no-op forever.
// Stop the interval explicitly so the process doesn't carry a timer per dead
@@ -622,38 +736,70 @@ app.whenReady().then(async () => {
// ws://127.0.0.1:6769 is stable; a second dev instance still falls back via
// ws-transport's EADDRINUSE handler.
const devWsPort = is.dev && !isE2E ? 6769 : undefined
let serveOptions: ServeOptions | null = null
try {
serveOptions = isServeMode ? getServeOptions() : null
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
app.exit(1)
return
}
runtimeRpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: app.getPath('userData'),
enableWebSocket: true,
...(isE2E ? { wsPort: 0 } : {}),
...(devWsPort !== undefined ? { wsPort: devWsPort } : {})
...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {})
})
registerMobileHandlers(runtimeRpc)
await startFirstWindowStartupServices({
// Why: the persistent-terminal daemon is always started. If it fails, the
// LocalPtyProvider remains as the implicit fallback — terminals work, just
// without cross-restart persistence.
startDaemonPtyProvider: () => initDaemonPtyProvider(),
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state,
// so the hook server must start before restored terminals can mount.
startAgentHookServer: () =>
agentHookServer.start({
env: app.isPackaged ? 'production' : 'development',
// Why: hooks source this endpoint file at invocation time, so old PTY
// env still reaches the current Orca process after an app restart.
userDataPath: app.getPath('userData')
}),
onDaemonError: (error) => {
console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error)
},
onAgentHookServerError: (error) => {
// Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar
// enrichment only. Orca must still boot if the loopback receiver fails.
console.error('[agent-hooks] Failed to start local hook server:', error)
}
})
if (!isServeMode) {
await startFirstWindowStartupServices({
// Why: the persistent-terminal daemon is desktop-only. Headless
// `orca serve` registers its PTY runtime below and must not spawn the
// desktop daemon or hook loopback listener.
startDaemonPtyProvider: () => initDaemonPtyProvider(),
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state,
// so the hook server must start before restored terminals can mount.
startAgentHookServer: () =>
agentHookServer.start({
env: app.isPackaged ? 'production' : 'development',
// Why: hooks source this endpoint file at invocation time, so old PTY
// env still reaches the current Orca process after an app restart.
userDataPath: app.getPath('userData')
}),
onDaemonError: (error) => {
console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error)
},
onAgentHookServerError: (error) => {
// Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar
// enrichment only. Orca must still boot if the loopback receiver fails.
console.error('[agent-hooks] Failed to start local hook server:', error)
}
})
}
if (serveOptions) {
registerHeadlessPtyRuntime(
runtime,
() => codexRuntimeHome!.prepareForCodexLaunch(),
() => store!.getSettings(),
() => claudeRuntimeAuth!.prepareForClaudeLaunch(),
store
)
// Why: headless servers have no renderer graph publisher. Publish an
// explicit empty graph so status clients see a ready server while
// renderer-only operations still fail at their own window boundary.
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
await runtimeRpc.start().catch((error) => {
console.error('[runtime] Failed to start headless RPC transport:', error)
throw error
})
installServeSignalHandlers()
await printServeReady(serveOptions)
return
}
// Why: once the hook server is ready (or has already failed open), window
// creation and runtime RPC startup are independent.
+10 -1
View File
@@ -179,7 +179,16 @@ describe('fs:importExternalPaths — SSH operations', () => {
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`)
expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, {
allowExisting: false
})
expect(uploadDirMock).toHaveBeenCalledWith(
mockSftp,
path.resolve('/tmp/dropped/assets'),
`${destDir}/assets`,
path.resolve('/tmp/dropped/assets'),
{ exclusive: true }
)
})
it('reports per-item failure when deconfliction throws', async () => {
+46 -3
View File
@@ -1,4 +1,6 @@
import path from 'path'
import { constants } from 'fs'
import { Readable, Writable } from 'stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
@@ -8,7 +10,9 @@ const {
mkdirMock,
realpathMock,
copyFileMock,
openMock,
readdirMock,
unlinkMock,
sftpExistsMock,
uploadFileMock,
uploadDirMock,
@@ -20,7 +24,9 @@ const {
mkdirMock: vi.fn(),
realpathMock: vi.fn(),
copyFileMock: vi.fn(),
openMock: vi.fn(),
readdirMock: vi.fn(),
unlinkMock: vi.fn(),
sftpExistsMock: vi.fn(),
uploadFileMock: vi.fn(),
uploadDirMock: vi.fn(),
@@ -36,7 +42,10 @@ vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
realpath: realpathMock,
copyFile: copyFileMock,
readdir: readdirMock
open: openMock,
readdir: readdirMock,
unlink: unlinkMock,
rm: vi.fn()
}))
vi.mock('../ssh/sftp-upload', () => ({
sftpPathExists: sftpExistsMock,
@@ -92,7 +101,9 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
mkdirMock,
realpathMock,
copyFileMock,
openMock,
readdirMock,
unlinkMock,
sftpExistsMock,
uploadFileMock,
uploadDirMock,
@@ -105,6 +116,30 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
})
realpathMock.mockImplementation(async (p: string) => p)
lstatMock.mockRejectedValue(enoent())
openMock.mockImplementation(async (_p: string, flags: unknown) => {
if (flags === 'wx') {
return {
createWriteStream: () =>
new Writable({
write(_chunk, _encoding, callback) {
callback()
}
}),
close: vi.fn().mockResolvedValue(undefined)
}
}
return {
stat: vi.fn().mockResolvedValue({
size: 12,
ino: 1,
dev: 1,
isFile: () => true
}),
createReadStream: () => Readable.from([Buffer.from('file-content')]),
close: vi.fn().mockResolvedValue(undefined)
}
})
unlinkMock.mockResolvedValue(undefined)
sftpExistsMock.mockResolvedValue(false)
uploadFileMock.mockResolvedValue(undefined)
uploadDirMock.mockResolvedValue(undefined)
@@ -121,7 +156,12 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' })
expect(uploadFileMock).toHaveBeenCalled()
expect(uploadFileMock).toHaveBeenCalledWith(
mockSftp,
path.resolve('/tmp/dropped/file.txt'),
`${destDir}/file.txt`,
{ exclusive: true }
)
expect(copyFileMock).not.toHaveBeenCalled()
})
@@ -132,7 +172,10 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
destDir: path.resolve('/workspace/repo/src')
})
expect(results[0]).toMatchObject({ status: 'imported' })
expect(copyFileMock).toHaveBeenCalled()
expect(openMock).toHaveBeenCalledWith(
path.resolve('/tmp/dropped/file.txt'),
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
)
})
it('returns empty results without opening SFTP', async () => {
+6 -4
View File
@@ -1,4 +1,4 @@
import { lstat, readdir } from 'fs/promises'
import { lstat, readdir, realpath } from 'fs/promises'
import { basename, join, posix, resolve } from 'path'
import type { SFTPWrapper } from 'ssh2'
import { authorizeExternalPath, isENOENT } from './filesystem-auth'
@@ -124,10 +124,12 @@ async function importOneSourceSsh(
const renamed = finalName !== originalName
if (isDir) {
await mkdirSftp(sftp, destPath)
await uploadDirectory(sftp, resolvedSource, destPath)
await mkdirSftp(sftp, destPath, { allowExisting: false })
await uploadDirectory(sftp, resolvedSource, destPath, await realpath(resolvedSource), {
exclusive: true
})
} else {
await uploadFile(sftp, resolvedSource, destPath)
await uploadFile(sftp, resolvedSource, destPath, { exclusive: true })
}
return {
+276 -14
View File
@@ -1,17 +1,34 @@
/* eslint-disable max-lines -- Why: import tests cover local copy, SSH routing,
symlink safety, and runtime-upload staging against one shared IPC fixture. */
import path from 'path'
import { constants } from 'fs'
import { Readable, Writable } from 'stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
const { handleMock, lstatMock, mkdirMock, realpathMock, copyFileMock, readdirMock } = vi.hoisted(
() => ({
handleMock: vi.fn(),
lstatMock: vi.fn(),
mkdirMock: vi.fn(),
realpathMock: vi.fn(),
copyFileMock: vi.fn(),
readdirMock: vi.fn()
})
)
const {
handleMock,
lstatMock,
mkdirMock,
realpathMock,
copyFileMock,
openMock,
readFileMock,
readdirMock,
rmMock,
unlinkMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
lstatMock: vi.fn(),
mkdirMock: vi.fn(),
realpathMock: vi.fn(),
copyFileMock: vi.fn(),
openMock: vi.fn(),
readFileMock: vi.fn(),
readdirMock: vi.fn(),
rmMock: vi.fn(),
unlinkMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock }
@@ -20,11 +37,15 @@ vi.mock('electron', () => ({
vi.mock('fs/promises', () => ({
lstat: lstatMock,
mkdir: mkdirMock,
open: openMock,
rename: vi.fn(),
writeFile: vi.fn(),
realpath: realpathMock,
copyFile: copyFileMock,
readdir: readdirMock
readFile: readFileMock,
readdir: readdirMock,
rm: rmMock,
unlink: unlinkMock
}))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
@@ -50,7 +71,14 @@ describe('fs:importExternalPaths', () => {
const resolvedPath = path.resolve(filePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
return {
size: 12,
ino: 1,
dev: 1,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
}
}
throw enoent()
})
@@ -62,6 +90,17 @@ describe('fs:importExternalPaths', () => {
if (p === resolvedDir) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
const entry = entries.find((e) => path.join(resolvedDir, e.name) === p)
if (entry) {
return {
size: entry.isDir ? 0 : 12,
ino: entry.isDir ? 2 : 3,
dev: 1,
isFile: () => !entry.isDir,
isDirectory: () => entry.isDir,
isSymbolicLink: () => false
}
}
throw enoent()
})
readdirMock.mockImplementation(async () => {
@@ -84,6 +123,35 @@ describe('fs:importExternalPaths', () => {
})
}
function mockLocalCopyOpenSuccess(content = Buffer.from('file-content')): void {
openMock.mockImplementation(async (_p: string, flags: unknown) => {
if (flags === 'wx') {
const written: Buffer[] = []
return {
createWriteStream: () =>
new Writable({
write(chunk, _encoding, callback) {
written.push(Buffer.from(chunk))
callback()
}
}),
close: vi.fn().mockResolvedValue(undefined),
written
}
}
return {
stat: vi.fn().mockResolvedValue({
size: content.byteLength,
ino: 1,
dev: 1,
isFile: () => true
}),
createReadStream: () => Readable.from([content]),
close: vi.fn().mockResolvedValue(undefined)
}
})
}
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
@@ -91,7 +159,11 @@ describe('fs:importExternalPaths', () => {
mkdirMock.mockReset()
realpathMock.mockReset()
copyFileMock.mockReset()
openMock.mockReset()
readFileMock.mockReset()
readdirMock.mockReset()
rmMock.mockReset()
unlinkMock.mockReset()
handleMock.mockImplementation((channel: string, handler: never) => {
handlers.set(channel, handler)
@@ -101,7 +173,11 @@ describe('fs:importExternalPaths', () => {
lstatMock.mockRejectedValue(enoent())
mkdirMock.mockResolvedValue(undefined)
copyFileMock.mockResolvedValue(undefined)
mockLocalCopyOpenSuccess()
readFileMock.mockResolvedValue(Buffer.from('file-content'))
readdirMock.mockResolvedValue([])
rmMock.mockResolvedValue(undefined)
unlinkMock.mockResolvedValue(undefined)
registerFilesystemMutationHandlers(store as never)
})
@@ -122,10 +198,44 @@ describe('fs:importExternalPaths', () => {
renamed: false,
destPath: path.join(destDir, 'logo.png')
})
expect(copyFileMock).toHaveBeenCalledWith(
expect(openMock).toHaveBeenCalledWith(
path.resolve(sourcePath),
path.join(destDir, 'logo.png')
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
)
expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx')
expect(copyFileMock).not.toHaveBeenCalled()
})
it('fails local import instead of clobbering when the chosen destination appears late', async () => {
const sourcePath = '/tmp/dropped/logo.png'
mockSourceFile(sourcePath)
openMock.mockImplementation(async (_p: string, flags: unknown) => {
if (flags === 'wx') {
throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' })
}
return {
stat: vi.fn().mockResolvedValue({
size: 12,
ino: 1,
dev: 1,
isFile: () => true
}),
createReadStream: () => Readable.from([Buffer.from('file-content')]),
close: vi.fn().mockResolvedValue(undefined)
}
})
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; reason?: string }[] }
expect(result.results[0]).toMatchObject({ status: 'failed', reason: 'EEXIST' })
expect(openMock).toHaveBeenCalledWith(
path.resolve(sourcePath),
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
)
expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx')
})
it('imports multiple files in one batch', async () => {
@@ -276,6 +386,44 @@ describe('fs:importExternalPaths', () => {
expect(copyFileMock).not.toHaveBeenCalled()
})
it('fails and removes output if a local directory entry becomes a symlink after pre-scan', async () => {
const sourcePath = '/tmp/dropped/mixeddir'
const resolvedSource = path.resolve(sourcePath)
const childPath = path.join(resolvedSource, 'normal.txt')
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedSource) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
if (p === childPath) {
return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true }
}
throw enoent()
})
readdirMock.mockResolvedValue([
{
name: 'normal.txt',
isDirectory: () => false,
isSymbolicLink: () => false,
isFile: () => true
}
])
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; reason?: string }[] }
expect(result.results[0]).toMatchObject({
status: 'failed',
reason: "Symlink not allowed in 'normal.txt'"
})
expect(openMock).not.toHaveBeenCalledWith(childPath, expect.anything())
expect(rmMock).toHaveBeenCalledWith(path.join(destDir, 'mixeddir'), {
recursive: true,
force: true
})
})
it('rejects unauthorized destinations', async () => {
const sourcePath = '/tmp/dropped/file.txt'
mockSourceFile(sourcePath)
@@ -328,4 +476,118 @@ describe('fs:importExternalPaths', () => {
reason: 'missing'
})
})
it('stages external files for runtime upload without copying into the local worktree', async () => {
const sourcePath = '/tmp/dropped/logo.png'
const resolvedPath = path.resolve(sourcePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return {
size: 4,
ino: 1,
dev: 1,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
}
}
throw enoent()
})
const closeMock = vi.fn().mockResolvedValue(undefined)
const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png'))
openMock.mockResolvedValue({
stat: vi.fn().mockResolvedValue({
size: 4,
ino: 1,
dev: 1,
isFile: () => true
}),
readFile: readFileHandleMock,
close: closeMock
})
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
sourcePaths: [sourcePath]
})) as { sources: unknown[] }
expect(result.sources).toEqual([
{
sourcePath,
status: 'staged',
name: 'logo.png',
kind: 'file',
entries: [{ relativePath: '', kind: 'file', contentBase64: 'cG5n' }]
}
])
expect(copyFileMock).not.toHaveBeenCalled()
expect(readFileHandleMock).toHaveBeenCalled()
expect(closeMock).toHaveBeenCalled()
})
it('fails runtime upload staging when a file changes between lstat and open', async () => {
const sourcePath = '/tmp/dropped/logo.png'
const resolvedPath = path.resolve(sourcePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return {
size: 4,
ino: 1,
dev: 1,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
}
}
throw enoent()
})
const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png'))
openMock.mockResolvedValue({
stat: vi.fn().mockResolvedValue({
size: 4,
ino: 2,
dev: 1,
isFile: () => true
}),
readFile: readFileHandleMock,
close: vi.fn().mockResolvedValue(undefined)
})
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
sourcePaths: [sourcePath]
})) as { sources: { status: string; reason?: string }[] }
expect(result.sources[0]).toMatchObject({
status: 'failed',
reason: "File changed during upload staging: ''"
})
expect(readFileHandleMock).not.toHaveBeenCalled()
})
it('fails runtime upload staging when a checked directory resolves outside the upload root', async () => {
const sourcePath = '/tmp/dropped/assets'
const resolvedPath = path.resolve(sourcePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return {
isFile: () => false,
isDirectory: () => true,
isSymbolicLink: () => false
}
}
throw enoent()
})
readdirMock.mockResolvedValue([])
realpathMock
.mockResolvedValueOnce(resolvedPath)
.mockResolvedValueOnce(path.resolve('/private/assets'))
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
sourcePaths: [sourcePath]
})) as { sources: { status: string; reason?: string }[] }
expect(result.sources[0]).toMatchObject({
status: 'failed',
reason: "Path escaped upload root during staging: ''"
})
})
})
+41 -5
View File
@@ -2,16 +2,16 @@ import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
const { handleMock, lstatMock, mkdirMock, renameMock, writeFileMock, realpathMock } = vi.hoisted(
() => ({
const { handleMock, copyFileMock, lstatMock, mkdirMock, renameMock, writeFileMock, realpathMock } =
vi.hoisted(() => ({
handleMock: vi.fn(),
copyFileMock: vi.fn(),
lstatMock: vi.fn(),
mkdirMock: vi.fn(),
renameMock: vi.fn(),
writeFileMock: vi.fn(),
realpathMock: vi.fn()
})
)
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock }
@@ -23,11 +23,15 @@ vi.mock('fs/promises', () => ({
rename: renameMock,
writeFile: writeFileMock,
realpath: realpathMock,
copyFile: vi.fn(),
copyFile: copyFileMock,
readdir: vi.fn()
}))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
// Why: paths are resolved via path.resolve() in production code, so test
// data must use resolved paths to avoid Unix-vs-Windows mismatches.
@@ -58,6 +62,7 @@ describe('registerFilesystemMutationHandlers', () => {
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
copyFileMock.mockReset()
lstatMock.mockReset()
mkdirMock.mockReset()
renameMock.mockReset()
@@ -74,6 +79,7 @@ describe('registerFilesystemMutationHandlers', () => {
mkdirMock.mockResolvedValue(undefined)
writeFileMock.mockResolvedValue(undefined)
renameMock.mockResolvedValue(undefined)
copyFileMock.mockResolvedValue(undefined)
registerFilesystemMutationHandlers(store as never)
})
@@ -210,6 +216,36 @@ describe('registerFilesystemMutationHandlers', () => {
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
})
// ── fs:copy ────────────────────────────────────────────────────
it('copies a file without overwriting an existing destination', async () => {
const sourcePath = path.resolve('/workspace/repo/source.ts')
const destinationPath = path.resolve('/workspace/repo/source copy.ts')
await handlers.get('fs:copy')!(null, { sourcePath, destinationPath })
expect(mkdirMock).toHaveBeenCalledWith(path.resolve('/workspace/repo'), { recursive: true })
expect(copyFileMock).toHaveBeenCalledWith(sourcePath, destinationPath, expect.any(Number))
})
it('routes copy through the SSH filesystem provider when a connection is present', async () => {
const copy = vi.fn().mockResolvedValue(undefined)
registerSshFilesystemProvider('ssh-1', { copy } as never)
try {
await handlers.get('fs:copy')!(null, {
sourcePath: '/home/me/repo/source.ts',
destinationPath: '/home/me/repo/source copy.ts',
connectionId: 'ssh-1'
})
} finally {
unregisterSshFilesystemProvider('ssh-1')
}
expect(copy).toHaveBeenCalledWith('/home/me/repo/source.ts', '/home/me/repo/source copy.ts')
expect(copyFileMock).not.toHaveBeenCalled()
})
// ── Edge cases ─────────────────────────────────────────────────
it('propagates non-ENOENT lstat errors in assertNotExists', async () => {
+343 -7
View File
@@ -1,6 +1,21 @@
/* eslint-disable max-lines -- Why: filesystem mutation IPC handlers stay centralized so
authorization, SSH routing, and external import behavior remain audited together. */
import { ipcMain } from 'electron'
import { copyFile, lstat, mkdir, readdir, rename, writeFile } from 'fs/promises'
import { basename, dirname, join, resolve } from 'path'
import { constants } from 'fs'
import {
copyFile,
lstat,
mkdir,
open,
readdir,
realpath,
rename,
rm,
unlink,
writeFile
} from 'fs/promises'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'
import { pipeline } from 'stream/promises'
import type { Store } from '../persistence'
import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
@@ -117,14 +132,42 @@ export function registerFilesystemMutationHandlers(store: Store): void {
}
)
ipcMain.handle(
'fs:copy',
async (
_event,
args: { sourcePath: string; destinationPath: string; connectionId?: string }
): Promise<void> => {
if (args.connectionId) {
const provider = getSshFilesystemProvider(args.connectionId)
if (!provider) {
throw new Error(`No filesystem provider for connection "${args.connectionId}"`)
}
return provider.copy(args.sourcePath, args.destinationPath)
}
const sourcePath = await resolveAuthorizedPath(args.sourcePath, store, {
preserveSymlink: true
})
const destinationPath = await resolveAuthorizedPath(args.destinationPath, store, {
preserveSymlink: true
})
await mkdir(dirname(destinationPath), { recursive: true })
// Why: duplicate/copy callers deconflict before copying. COPYFILE_EXCL
// keeps a late race from silently overwriting an existing file.
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL)
}
)
ipcMain.handle(
'fs:importExternalPaths',
async (
_event,
args: { sourcePaths: string[]; destDir: string; connectionId?: string }
args: { sourcePaths: string[]; destDir: string; connectionId?: string; ensureDir?: boolean }
): Promise<{ results: ImportItemResult[] }> => {
if (args.connectionId) {
return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId)
return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, {
ensureDir: args.ensureDir
})
}
// Why: destDir must be authorized before any copy work begins. If the
@@ -148,6 +191,20 @@ export function registerFilesystemMutationHandlers(store: Store): void {
}
)
ipcMain.handle(
'fs:stageExternalPathsForRuntimeUpload',
async (
_event,
args: { sourcePaths: string[] }
): Promise<{ sources: StagedExternalImportSource[] }> => {
const sources: StagedExternalImportSource[] = []
for (const sourcePath of args.sourcePaths) {
sources.push(await stageOneSourceForRuntimeUpload(sourcePath))
}
return { sources }
}
)
// Why: terminal drag-and-drop resolver. Local worktrees pass paths through
// unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees
// upload each path into `${worktreePath}/.orca/drops/` and return remote
@@ -217,6 +274,32 @@ export type ImportItemResult =
reason: string
}
export type StagedExternalImportSource =
| {
sourcePath: string
status: 'staged'
name: string
kind: 'file' | 'directory'
entries: StagedExternalImportEntry[]
}
| {
sourcePath: string
status: 'skipped'
reason: ImportSkipReason
}
| {
sourcePath: string
status: 'failed'
reason: string
}
export type StagedExternalImportEntry =
| { relativePath: string; kind: 'directory' }
| { relativePath: string; kind: 'file'; contentBase64: string }
const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024
const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
// ─── External Import Implementation ─────────────────────────────────
/**
@@ -288,8 +371,13 @@ async function importOneSource(
const renamed = finalName !== originalName
try {
await (isDir ? recursiveCopyDir(resolvedSource, destPath) : copyFile(resolvedSource, destPath))
await (isDir
? recursiveCopyDir(resolvedSource, destPath)
: copyLocalFileNoFollow(resolvedSource, destPath))
} catch (error) {
if (isDir) {
await rm(destPath, { recursive: true, force: true }).catch(() => {})
}
return {
sourcePath,
status: 'failed',
@@ -306,6 +394,194 @@ async function importOneSource(
}
}
async function stageOneSourceForRuntimeUpload(
sourcePath: string
): Promise<StagedExternalImportSource> {
const resolvedSource = resolve(sourcePath)
// Why: runtime uploads read client-local paths in the client main process;
// authorize before lstat/readFile just like local copy imports.
authorizeExternalPath(resolvedSource)
let sourceStat: Awaited<ReturnType<typeof lstat>>
try {
sourceStat = await lstat(resolvedSource)
} catch (error) {
if (isENOENT(error)) {
return { sourcePath, status: 'skipped', reason: 'missing' }
}
if (
error instanceof Error &&
'code' in error &&
((error as NodeJS.ErrnoException).code === 'EACCES' ||
(error as NodeJS.ErrnoException).code === 'EPERM')
) {
return { sourcePath, status: 'skipped', reason: 'permission-denied' }
}
return {
sourcePath,
status: 'failed',
reason: error instanceof Error ? error.message : String(error)
}
}
if (sourceStat.isSymbolicLink()) {
return { sourcePath, status: 'skipped', reason: 'symlink' }
}
if (!sourceStat.isFile() && !sourceStat.isDirectory()) {
return { sourcePath, status: 'skipped', reason: 'unsupported' }
}
if (sourceStat.isDirectory() && (await preScanForSymlinks(resolvedSource))) {
return { sourcePath, status: 'skipped', reason: 'symlink' }
}
try {
const entries = sourceStat.isDirectory()
? await stageDirectoryEntries(resolvedSource)
: [await stageFileEntry(resolvedSource, '')]
return {
sourcePath,
status: 'staged',
name: basename(resolvedSource),
kind: sourceStat.isDirectory() ? 'directory' : 'file',
entries
}
} catch (error) {
return {
sourcePath,
status: 'failed',
reason: error instanceof Error ? error.message : String(error)
}
}
}
async function stageDirectoryEntries(rootPath: string): Promise<StagedExternalImportEntry[]> {
const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }]
let totalBytes = 0
const rootRealPath = await realpath(rootPath)
async function visit(dirPath: string): Promise<void> {
const dirStat = await lstat(dirPath)
if (dirStat.isSymbolicLink()) {
throw new Error(
`Symlink not allowed in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'`
)
}
if (!dirStat.isDirectory()) {
throw new Error(
`Unsupported file type in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'`
)
}
await assertRealPathInsideRoot(
rootRealPath,
dirPath,
normalizeRelativeUploadPath(relative(rootPath, dirPath))
)
const dirEntries = await readdir(dirPath, { withFileTypes: true })
for (const entry of dirEntries) {
const childPath = join(dirPath, entry.name)
const childRelativePath = normalizeRelativeUploadPath(relative(rootPath, childPath))
if (entry.isDirectory()) {
const childStat = await lstat(childPath)
if (childStat.isSymbolicLink()) {
throw new Error(`Symlink not allowed in '${childRelativePath}'`)
}
if (!childStat.isDirectory()) {
throw new Error(`Unsupported file type in '${childRelativePath}'`)
}
entries.push({ relativePath: childRelativePath, kind: 'directory' })
await visit(childPath)
continue
}
if (!entry.isFile()) {
throw new Error(`Unsupported file type in '${childRelativePath}'`)
}
const statResult = await lstat(childPath)
totalBytes += statResult.size
assertRemoteUploadBudget(childRelativePath, statResult.size, totalBytes)
entries.push(await stageFileEntry(childPath, childRelativePath, rootRealPath))
}
}
await visit(rootPath)
return entries
}
async function stageFileEntry(
filePath: string,
relativePath: string,
rootRealPath?: string
): Promise<StagedExternalImportEntry> {
const statResult = await lstat(filePath)
const displayPath = normalizeRelativeUploadPath(relativePath)
if (statResult.isSymbolicLink()) {
throw new Error(`Symlink not allowed in '${displayPath}'`)
}
if (!statResult.isFile()) {
throw new Error(`Unsupported file type in '${displayPath}'`)
}
if (rootRealPath) {
await assertRealPathInsideRoot(rootRealPath, filePath, displayPath)
}
assertRemoteUploadBudget(relativePath, statResult.size, statResult.size)
const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
try {
const openedStat = await fileHandle.stat()
if (!openedStat.isFile()) {
throw new Error(`Unsupported file type in '${displayPath}'`)
}
if (
openedStat.size !== statResult.size ||
(statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) ||
(statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev)
) {
throw new Error(`File changed during upload staging: '${displayPath}'`)
}
assertRemoteUploadBudget(relativePath, openedStat.size, openedStat.size)
const buffer = await fileHandle.readFile()
const afterReadStat = await fileHandle.stat()
if (afterReadStat.size !== openedStat.size) {
throw new Error(`File changed during upload staging: '${displayPath}'`)
}
return {
relativePath: displayPath,
kind: 'file',
contentBase64: buffer.toString('base64')
}
} finally {
await fileHandle.close()
}
}
async function assertRealPathInsideRoot(
rootRealPath: string,
candidatePath: string,
displayPath: string
): Promise<void> {
const candidateRealPath = await realpath(candidatePath)
const relativeToRoot = relative(rootRealPath, candidateRealPath)
if (relativeToRoot !== '' && (relativeToRoot.startsWith('..') || isAbsolute(relativeToRoot))) {
throw new Error(`Path escaped upload root during staging: '${displayPath}'`)
}
}
function assertRemoteUploadBudget(
relativePath: string,
fileBytes: number,
totalBytes: number
): void {
if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) {
throw new Error(`'${relativePath}' is too large for remote import`)
}
if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) {
throw new Error('Remote import is too large')
}
}
function normalizeRelativeUploadPath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
}
/**
* Pre-scan a directory tree for symlinks. Returns true if any symlink
* is found anywhere in the subtree.
@@ -332,12 +608,72 @@ async function preScanForSymlinks(dirPath: string): Promise<boolean> {
* buffering entire files into memory.
*/
async function recursiveCopyDir(srcDir: string, destDir: string): Promise<void> {
await mkdir(destDir, { recursive: true })
await mkdir(destDir, { recursive: false })
const entries = await readdir(srcDir, { withFileTypes: true })
for (const entry of entries) {
const srcPath = join(srcDir, entry.name)
const dstPath = join(destDir, entry.name)
await (entry.isDirectory() ? recursiveCopyDir(srcPath, dstPath) : copyFile(srcPath, dstPath))
const statResult = await lstat(srcPath)
if (statResult.isSymbolicLink()) {
throw new Error(`Symlink not allowed in '${entry.name}'`)
}
if (statResult.isDirectory()) {
await recursiveCopyDir(srcPath, dstPath)
continue
}
if (!statResult.isFile()) {
throw new Error(`Unsupported file type in '${entry.name}'`)
}
await copyLocalFileNoFollow(srcPath, dstPath, statResult)
}
}
async function copyLocalFileNoFollow(
srcPath: string,
dstPath: string,
statResult?: Awaited<ReturnType<typeof lstat>>
): Promise<void> {
const beforeOpenStat = statResult ?? (await lstat(srcPath))
if (beforeOpenStat.isSymbolicLink()) {
throw new Error(`Symlink not allowed in '${basename(srcPath)}'`)
}
if (!beforeOpenStat.isFile()) {
throw new Error(`Unsupported file type in '${basename(srcPath)}'`)
}
let destinationCreated = false
const sourceHandle = await open(srcPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
let destinationHandle: Awaited<ReturnType<typeof open>> | null = null
try {
const openedStat = await sourceHandle.stat()
if (
!openedStat.isFile() ||
(typeof beforeOpenStat.size === 'number' && openedStat.size !== beforeOpenStat.size) ||
(typeof beforeOpenStat.ino === 'number' &&
beforeOpenStat.ino !== 0 &&
openedStat.ino !== 0 &&
openedStat.ino !== beforeOpenStat.ino) ||
(typeof beforeOpenStat.dev === 'number' &&
beforeOpenStat.dev !== 0 &&
openedStat.dev !== 0 &&
openedStat.dev !== beforeOpenStat.dev)
) {
throw new Error(`File changed during import: '${basename(srcPath)}'`)
}
// Why: copyFile(path, path) would follow a source symlink if the source is
// swapped after validation. Streaming from an O_NOFOLLOW handle keeps the
// authorized file identity pinned for the copy.
destinationHandle = await open(dstPath, 'wx')
destinationCreated = true
await pipeline(sourceHandle.createReadStream(), destinationHandle.createWriteStream())
} catch (error) {
if (destinationCreated) {
await unlink(dstPath).catch(() => {})
}
throw error
} finally {
await sourceHandle.close().catch(() => {})
await destinationHandle?.close().catch(() => {})
}
}
+31
View File
@@ -28,16 +28,25 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
}))
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
import { stat } from 'fs/promises'
import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
describe('registerFilesystemWatcherHandlers', () => {
const handlers: HandlerMap = {}
const originalPlatform = process.platform
beforeEach(() => {
vi.useRealTimers()
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
vi.mocked(stat).mockReset()
vi.mocked(subscribeParcelWatcher).mockReset()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
@@ -47,6 +56,28 @@ describe('registerFilesystemWatcherHandlers', () => {
registerFilesystemWatcherHandlers()
})
it('pins Parcel to the Windows backend for local Windows watches', async () => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never)
await handlers['fs:watchWorktree'](
{ sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } },
{ worktreePath: 'C:\\repo' }
)
expect(subscribeParcelWatcher).toHaveBeenCalledWith(
expect.any(String),
expect.any(Function),
expect.objectContaining({ backend: 'windows' })
)
await closeAllWatchers()
})
it('quietly skips SSH worktree watches while the filesystem provider is unavailable', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+9 -3
View File
@@ -239,6 +239,14 @@ async function createWatcher(rootKey: string, rootPath: string): Promise<Watched
// therefore never unsubscribed), leaking a native file-watcher handle.
let errorCleanedUp = false
const watcherOptions = {
ignore: WATCHER_IGNORE_DIRS,
// Why: Parcel checks Watchman before the native Windows backend by
// default, and Windows prints a shell-level "watchman not recognized"
// error for that probe. Pinning the backend keeps local watches quiet.
...(process.platform === 'win32' ? { backend: 'windows' as const } : {})
}
root.subscription = await watcher.subscribe(
rootPath,
(err, events) => {
@@ -280,9 +288,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise<Watched
root.batch.events.push(...events)
scheduleBatchFlush(rootKey, root)
},
{
ignore: WATCHER_IGNORE_DIRS
}
watcherOptions
)
// Why: if the error callback already fired and cleaned up watchedRoots
+72
View File
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock } = vi.hoisted(() => ({
handleMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock }
}))
vi.mock('qrcode', () => ({
default: {
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,qr')
}
}))
import { registerMobileHandlers } from './mobile'
describe('registerMobileHandlers', () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>()
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler)
})
})
it('lists only paired mobile-scoped devices', () => {
const rpcServer = {
getDeviceRegistry: () => ({
listDevices: () => [
{
deviceId: 'mobile-1',
name: 'Phone',
scope: 'mobile',
pairedAt: 1,
lastSeenAt: 2
},
{
deviceId: 'runtime-1',
name: 'CLI',
scope: 'runtime',
pairedAt: 1,
lastSeenAt: 2
},
{
deviceId: 'pending-mobile',
name: 'Pending',
scope: 'mobile',
pairedAt: 1,
lastSeenAt: 0
}
]
})
}
registerMobileHandlers(rpcServer as never)
expect(handlers.get('mobile:listDevices')?.()).toEqual({
devices: [
{
deviceId: 'mobile-1',
name: 'Phone',
pairedAt: 1,
lastSeenAt: 2
}
]
})
})
})
+13 -28
View File
@@ -2,7 +2,6 @@ import { ipcMain } from 'electron'
import { networkInterfaces } from 'os'
import QRCode from 'qrcode'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
export type NetworkInterface = {
name: string
@@ -46,12 +45,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
ipcMain.handle(
'mobile:getPairingQR',
async (_event, args?: { address?: string; rotate?: boolean }) => {
const rawEndpoint = rpcServer.getWebSocketEndpoint()
const registry = rpcServer.getDeviceRegistry()
if (!rawEndpoint || !registry) {
return { available: false as const }
}
// Why: allow the caller to specify which network interface address to
// embed in the QR code. This supports overlay networks (Tailscale,
// ZeroTier) where the default LAN IP isn't reachable from the phone.
@@ -59,7 +52,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
if (!ip) {
return { available: false as const }
}
const endpoint = rawEndpoint.replace('0.0.0.0', ip)
// Why: coalesce repeated QR regenerations onto a single never-scanned
// pending token so the copy-button flow doesn't accumulate orphaned
@@ -68,24 +60,17 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
// `rotate: true` (explicit "Regenerate" intent because the prior token
// may have been exposed), we discard any pending token and mint a fresh
// one so the new QR carries a different credential.
const name = `Mobile ${new Date().toLocaleDateString()}`
const device = args?.rotate
? registry.rotatePendingDevice(name)
: registry.getOrCreatePendingDevice(name)
const publicKeyB64 = rpcServer.getE2EEPublicKey()
if (!publicKeyB64) {
const offer = rpcServer.createPairingOffer({
address: ip,
rotate: args?.rotate,
name: `Mobile ${new Date().toLocaleDateString()}`,
scope: 'mobile'
})
if (!offer.available) {
return { available: false as const }
}
const url = encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint,
deviceToken: device.token,
publicKeyB64
})
const qrDataUrl = await QRCode.toDataURL(url, {
const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, {
errorCorrectionLevel: 'M',
margin: 2,
width: 256
@@ -94,9 +79,9 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
return {
available: true as const,
qrDataUrl,
pairingUrl: url,
endpoint,
deviceId: device.deviceId
pairingUrl: offer.pairingUrl,
endpoint: offer.endpoint,
deviceId: offer.deviceId
}
}
)
@@ -112,7 +97,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
return {
devices: registry
.listDevices()
.filter((d) => d.lastSeenAt > 0)
.filter((d) => d.scope === 'mobile' && d.lastSeenAt > 0)
.map((d) => ({
deviceId: d.deviceId,
name: d.name,
@@ -127,7 +112,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
if (!registry) {
return { revoked: false }
}
return { revoked: registry.removeDevice(args.deviceId) }
return { revoked: rpcServer.revokeMobileDevice(args.deviceId) }
})
ipcMain.handle('mobile:isWebSocketReady', () => {
+35
View File
@@ -931,6 +931,13 @@ export function registerPtyHandlers(
return null
}
},
hasChildProcesses: async (ptyId) => {
try {
return await getProviderForPty(ptyId).hasChildProcesses(ptyId)
} catch {
return false
}
},
clearBuffer: async (ptyId) => {
// Why: desktop xterm owns local scrollback, while daemon/SSH providers
// own their own retained buffers. Clear both surfaces so mobile
@@ -1620,6 +1627,34 @@ export function registerPtyHandlers(
)
}
export function registerHeadlessPtyRuntime(
runtime: OrcaRuntimeService,
getSelectedCodexHomePath?: () => string | null,
getSettings?: () => GlobalSettings,
prepareClaudeAuth?: () => Promise<ClaudeRuntimeAuthPreparation>,
store?: Store
): void {
// Why: headless `orca serve` has no renderer window, but the runtime still
// needs the same PTY controller and provider listeners as desktop so remote
// clients can create, stream, inspect, and stop terminals.
const headlessWindow = {
isDestroyed: () => true,
webContents: {
send: () => {},
on: () => {},
removeListener: () => {}
}
} as unknown as BrowserWindow
registerPtyHandlers(
headlessWindow,
runtime,
getSelectedCodexHomePath,
getSettings,
prepareClaudeAuth,
store
)
}
/**
* Kill all PTY processes. Call on app quit.
*/
@@ -24,6 +24,7 @@ const {
registerUIHandlersMock,
registerFilesystemHandlersMock,
registerRuntimeHandlersMock,
registerRuntimeEnvironmentHandlersMock,
registerCodexAccountHandlersMock,
registerAgentHookHandlersMock,
registerAgentTrustHandlersMock,
@@ -65,6 +66,7 @@ const {
registerUIHandlersMock: vi.fn(),
registerFilesystemHandlersMock: vi.fn(),
registerRuntimeHandlersMock: vi.fn(),
registerRuntimeEnvironmentHandlersMock: vi.fn(),
registerCodexAccountHandlersMock: vi.fn(),
registerAgentHookHandlersMock: vi.fn(),
registerAgentTrustHandlersMock: vi.fn(),
@@ -194,6 +196,10 @@ vi.mock('./runtime', () => ({
registerRuntimeHandlers: registerRuntimeHandlersMock
}))
vi.mock('./runtime-environments', () => ({
registerRuntimeEnvironmentHandlers: registerRuntimeEnvironmentHandlersMock
}))
vi.mock('./codex-accounts', () => ({
registerCodexAccountHandlers: registerCodexAccountHandlersMock
}))
@@ -262,6 +268,7 @@ describe('registerCoreHandlers', () => {
registerUIHandlersMock.mockReset()
registerFilesystemHandlersMock.mockReset()
registerRuntimeHandlersMock.mockReset()
registerRuntimeEnvironmentHandlersMock.mockReset()
registerCodexAccountHandlersMock.mockReset()
registerAgentHookHandlersMock.mockReset()
registerAgentTrustHandlersMock.mockReset()
@@ -328,6 +335,7 @@ describe('registerCoreHandlers', () => {
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalled()
expect(registerCliHandlersMock).toHaveBeenCalled()
expect(registerPreflightHandlersMock).toHaveBeenCalled()
expect(registerShellHandlersMock).toHaveBeenCalled()
+2
View File
@@ -18,6 +18,7 @@ import { registerStatsHandlers } from './stats'
import { registerMemoryHandlers } from './memory'
import { registerRateLimitHandlers } from './rate-limits'
import { registerRuntimeHandlers } from './runtime'
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
import { registerNotesHandlers } from './notes'
import { registerNotificationHandlers } from './notifications'
import { registerNotebookHandlers } from './notebook'
@@ -120,6 +121,7 @@ export function registerCoreHandlers(
registerFilesystemHandlers(store)
registerFilesystemWatcherHandlers()
registerRuntimeHandlers(runtime)
registerRuntimeEnvironmentHandlers()
registerNotesHandlers(runtime)
registerClipboardHandlers()
registerUpdaterHandlers(store)
@@ -0,0 +1,92 @@
const REMOTE_RUNTIME_CALL_CONCURRENCY = 8
const REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY = 2
type QueuedRuntimeCall<T> = {
background: boolean
run: () => Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
}
type RuntimeCallQueue = {
active: number
backgroundActive: number
foreground: QueuedRuntimeCall<unknown>[]
background: QueuedRuntimeCall<unknown>[]
}
const runtimeCallQueues = new Map<string, RuntimeCallQueue>()
function isBackgroundRuntimeMethod(method: string): boolean {
return (
method === 'hostedReview.forBranch' ||
method === 'github.listWorkItems' ||
method === 'github.countWorkItems' ||
method === 'git.status' ||
method === 'git.conflictOperation' ||
method === 'git.branchCompare' ||
method === 'git.upstreamStatus'
)
}
function getRuntimeCallQueue(selector: string): RuntimeCallQueue {
let queue = runtimeCallQueues.get(selector)
if (!queue) {
queue = { active: 0, backgroundActive: 0, foreground: [], background: [] }
runtimeCallQueues.set(selector, queue)
}
return queue
}
export function enqueueRuntimeCall<T>(
selector: string,
method: string,
run: () => Promise<T>
): Promise<T> {
const queue = getRuntimeCallQueue(selector)
return new Promise<T>((resolve, reject) => {
const call: QueuedRuntimeCall<T> = {
background: isBackgroundRuntimeMethod(method),
run,
resolve,
reject
}
const targetQueue = call.background ? queue.background : queue.foreground
targetQueue.push(call as QueuedRuntimeCall<unknown>)
pumpRuntimeCallQueue(selector, queue)
})
}
function pumpRuntimeCallQueue(selector: string, queue: RuntimeCallQueue): void {
while (queue.active < REMOTE_RUNTIME_CALL_CONCURRENCY) {
let call = queue.foreground.shift()
if (!call && queue.backgroundActive < REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY) {
call = queue.background.shift()
}
if (!call) {
break
}
queue.active += 1
if (call.background) {
queue.backgroundActive += 1
}
// Why: remote WebSocket servers have a finite connection budget and each
// one-shot RPC currently opens its own encrypted socket. Background PR/task
// refreshes must not stampede the server and starve terminal/worktree calls.
void call
.run()
.then(call.resolve, call.reject)
.finally(() => {
queue.active = Math.max(0, queue.active - 1)
if (call.background) {
queue.backgroundActive = Math.max(0, queue.backgroundActive - 1)
}
if (queue.active === 0 && queue.foreground.length === 0 && queue.background.length === 0) {
runtimeCallQueues.delete(selector)
return
}
pumpRuntimeCallQueue(selector, queue)
})
}
}
@@ -0,0 +1,46 @@
import type { PairingOffer } from '../../shared/pairing'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection'
type CachedRuntimeConnection = {
pairingKey: string
connection: RemoteRuntimeRequestConnection
}
const requestConnections = new Map<string, CachedRuntimeConnection>()
export function sendRemoteRuntimeConnectionRequest<TResult>(
environmentId: string,
pairing: PairingOffer,
method: string,
params: unknown,
timeoutMs: number
): Promise<RuntimeRpcResponse<TResult>> {
const pairingKey = getPairingKey(pairing)
let cached = requestConnections.get(environmentId)
if (!cached || cached.pairingKey !== pairingKey) {
cached?.connection.close()
cached = {
pairingKey,
connection: new RemoteRuntimeRequestConnection(pairing)
}
requestConnections.set(environmentId, cached)
}
return cached.connection.request(method, params, timeoutMs)
}
export function closeRemoteRuntimeRequestConnection(environmentId: string): void {
const cached = requestConnections.get(environmentId)
requestConnections.delete(environmentId)
cached?.connection.close()
}
export function closeAllRemoteRuntimeRequestConnections(): void {
for (const environmentId of Array.from(requestConnections.keys())) {
closeRemoteRuntimeRequestConnection(environmentId)
}
}
function getPairingKey(pairing: PairingOffer): string {
return [pairing.endpoint, pairing.deviceToken, pairing.publicKeyB64].join('\0')
}
+615
View File
@@ -0,0 +1,615 @@
/* eslint-disable max-lines -- Why: this suite covers runtime environment
management, secret redaction, one-shot RPC, and streaming cleanup contracts. */
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encodePairingOffer } from '../../shared/pairing'
import * as environmentStore from '../../shared/runtime-environment-store'
const {
handleMock,
onMock,
getPathMock,
sendRemoteRuntimeRequestMock,
subscribeRemoteRuntimeRequestMock,
sendRemoteRuntimeConnectionRequestMock,
closeRemoteRuntimeRequestConnectionMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
onMock: vi.fn(),
getPathMock: vi.fn(),
sendRemoteRuntimeRequestMock: vi.fn(),
subscribeRemoteRuntimeRequestMock: vi.fn(),
sendRemoteRuntimeConnectionRequestMock: vi.fn(),
closeRemoteRuntimeRequestConnectionMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: getPathMock },
ipcMain: { handle: handleMock, on: onMock }
}))
vi.mock('../../shared/remote-runtime-client', () => ({
sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock,
subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock
}))
vi.mock('./runtime-environment-request-connections', () => ({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
return encodePairingOffer({
v: 2,
endpoint,
deviceToken: 'device-token',
publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64')
})
}
function handler<TArgs, TResult>(
channel: string
): (_event: unknown, args: TArgs) => TResult | Promise<TResult> {
const match = handleMock.mock.calls.find((call) => call[0] === channel)
expect(match).toBeTruthy()
return match![1] as (_event: unknown, args: TArgs) => TResult | Promise<TResult>
}
describe('registerRuntimeEnvironmentHandlers', () => {
let userDataPath: string
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-ipc-'))
getPathMock.mockReset()
getPathMock.mockReturnValue(userDataPath)
handleMock.mockReset()
onMock.mockReset()
sendRemoteRuntimeRequestMock.mockReset()
subscribeRemoteRuntimeRequestMock.mockReset()
sendRemoteRuntimeConnectionRequestMock.mockReset()
closeRemoteRuntimeRequestConnectionMock.mockReset()
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
})
it('registers desktop runtime environment management handlers', () => {
registerRuntimeEnvironmentHandlers()
expect(handleMock.mock.calls.map((call) => call[0])).toEqual([
'runtimeEnvironments:list',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:getStatus',
'runtimeEnvironments:call',
'runtimeEnvironments:subscribe',
'runtimeEnvironments:unsubscribe'
])
expect(onMock.mock.calls.map((call) => call[0])).toEqual([
'runtimeEnvironments:subscriptionBinary'
])
})
it('stores, resolves, lists, and removes environments under Electron userData', async () => {
registerRuntimeEnvironmentHandlers()
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
expect(JSON.stringify(added)).not.toContain('device-token')
expect(JSON.stringify(added)).not.toContain('publicKeyB64')
const list = handler<undefined, { id: string; name: string }[]>('runtimeEnvironments:list')
expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }])
expect(JSON.stringify(await list(null, undefined))).not.toContain('device-token')
const resolve = handler<{ selector: string }, { id: string; name: string }>(
'runtimeEnvironments:resolve'
)
expect(await resolve(null, { selector: 'desk' })).toMatchObject({
id: added.environment.id,
name: 'desk'
})
expect(JSON.stringify(await resolve(null, { selector: 'desk' }))).not.toContain('device-token')
const remove = handler<{ selector: string }, { removed: { id: string; name: string } }>(
'runtimeEnvironments:remove'
)
const removed = await remove(null, { selector: added.environment.id })
expect(removed).toMatchObject({
removed: { id: added.environment.id, name: 'desk' }
})
expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id)
expect(JSON.stringify(removed)).not.toContain('device-token')
expect(await list(null, undefined)).toEqual([])
})
it('checks a saved remote runtime and records the runtime id on success', async () => {
registerRuntimeEnvironmentHandlers()
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: { runtimeId: 'runtime-remote', graphStatus: 'ready' },
_meta: { runtimeId: 'runtime-remote' }
})
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
const getStatus = handler<
{ selector: string; timeoutMs?: number },
{ ok: true; result: { runtimeId: string } }
>('runtimeEnvironments:getStatus')
expect(await getStatus(null, { selector: 'desk', timeoutMs: 50 })).toMatchObject({
ok: true,
result: { runtimeId: 'runtime-remote' }
})
expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith(
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768', deviceToken: 'device-token' }),
'status.get',
undefined,
50
)
const resolve = handler<{ selector: string }, { id: string; runtimeId: string | null }>(
'runtimeEnvironments:resolve'
)
expect(await resolve(null, { selector: added.environment.id })).toMatchObject({
id: added.environment.id,
runtimeId: 'runtime-remote'
})
})
it('proxies generic one-shot RPC calls to the saved remote runtime', async () => {
registerRuntimeEnvironmentHandlers()
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'rpc-2',
ok: true,
result: { repos: [{ id: 'repo-1' }] },
_meta: { runtimeId: 'runtime-remote' }
})
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
const call = handler<
{ selector: string; method: string; params?: unknown; timeoutMs?: number },
{ ok: true; result: unknown }
>('runtimeEnvironments:call')
expect(
await call(null, { selector: 'desk', method: 'repo.list', timeoutMs: 75 })
).toMatchObject({
ok: true,
result: { repos: [{ id: 'repo-1' }] }
})
expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith(
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
'repo.list',
undefined,
75
)
expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled()
})
it('uses the cached request connection for terminal hot path RPCs', async () => {
registerRuntimeEnvironmentHandlers()
sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({
id: 'rpc-terminal',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime-remote' }
})
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
const call = handler<
{ selector: string; method: string; params?: unknown; timeoutMs?: number },
{ ok: true; result: unknown }
>('runtimeEnvironments:call')
expect(
await call(null, {
selector: 'desk',
method: 'terminal.send',
params: { terminal: 't1', text: 'a' },
timeoutMs: 75
})
).toMatchObject({
ok: true,
result: { send: { accepted: true } }
})
expect(sendRemoteRuntimeConnectionRequestMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
'terminal.send',
{ terminal: 't1', text: 'a' },
75
)
expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
})
it('limits background one-shot RPCs without blocking foreground runtime calls', async () => {
registerRuntimeEnvironmentHandlers()
const pendingBackground: ((value: unknown) => void)[] = []
sendRemoteRuntimeRequestMock.mockImplementation(async () => {
return await new Promise((resolve) => pendingBackground.push(resolve))
})
sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({
id: 'terminal-send',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime-remote' }
})
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
const call = handler<
{ selector: string; method: string; params?: unknown; timeoutMs?: number },
{ ok: true; result: unknown }
>('runtimeEnvironments:call')
const bg1 = call(null, { selector: 'desk', method: 'hostedReview.forBranch' })
const bg2 = call(null, { selector: 'desk', method: 'github.listWorkItems' })
await vi.waitFor(() => expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledTimes(2))
const bg3 = call(null, { selector: 'desk', method: 'git.status' })
const foreground = call(null, {
selector: 'desk',
method: 'terminal.send',
params: { terminal: 'term-1', text: 'a' }
})
await vi.waitFor(() =>
expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([
'hostedReview.forBranch',
'github.listWorkItems'
])
)
expect(sendRemoteRuntimeConnectionRequestMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
'terminal.send',
{ terminal: 'term-1', text: 'a' },
15_000
)
await expect(foreground).resolves.toMatchObject({
ok: true,
result: { send: { accepted: true } }
})
expect(pendingBackground).toHaveLength(2)
pendingBackground.shift()?.({
id: 'background-1',
ok: true,
result: null,
_meta: { runtimeId: 'runtime-remote' }
})
await vi.waitFor(() => expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledTimes(3))
expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([
'hostedReview.forBranch',
'github.listWorkItems',
'git.status'
])
pendingBackground.splice(0).forEach((resolve) =>
resolve({
id: 'background',
ok: true,
result: null,
_meta: { runtimeId: 'runtime-remote' }
})
)
await expect(bg1).resolves.toMatchObject({ ok: true })
await expect(bg2).resolves.toMatchObject({ ok: true })
await expect(bg3).resolves.toMatchObject({ ok: true })
})
it('starts and stops streaming subscriptions for a saved remote runtime', async () => {
registerRuntimeEnvironmentHandlers()
const close = vi.fn()
const sendBinary = vi.fn()
const markUsedSpy = vi.spyOn(environmentStore, 'markEnvironmentUsed')
subscribeRemoteRuntimeRequestMock.mockImplementation(
async (_pairing, _method, _params, _timeoutMs, callbacks) => {
callbacks.onResponse({
id: 'stream-1',
ok: true,
result: { type: 'subscribed' },
_meta: { runtimeId: 'runtime-remote' }
})
callbacks.onResponse({
id: 'stream-1',
ok: true,
result: { type: 'data', chunk: 'hello' },
_meta: { runtimeId: 'runtime-remote' }
})
callbacks.onBinary(new Uint8Array([1, 2, 3]))
return { requestId: 'stream-1', close, sendBinary }
}
)
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
const sent: unknown[] = []
const destroyedListenerRemoved = vi.fn()
const subscribe = handler<
{
selector: string
method: string
params?: unknown
timeoutMs?: number
subscriptionId?: string
},
{ subscriptionId: string; requestId: string }
>('runtimeEnvironments:subscribe')
const result = await subscribe(
{
sender: {
id: 1,
isDestroyed: () => false,
send: (_channel: string, payload: unknown) => sent.push(payload),
once: vi.fn(),
removeListener: destroyedListenerRemoved
}
},
{
selector: 'desk',
method: 'terminal.subscribe',
params: { terminal: 't1' },
timeoutMs: 25,
subscriptionId: 'preload-sub-1'
}
)
expect(result.requestId).toBe('stream-1')
expect(result.subscriptionId).toBe('preload-sub-1')
expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledWith(
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
'terminal.subscribe',
{ terminal: 't1' },
25,
expect.any(Object)
)
expect(sent).toEqual([
expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }),
expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }),
expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'binary' })
])
expect(markUsedSpy).toHaveBeenCalledTimes(1)
const binaryListener = onMock.mock.calls.find(
(call) => call[0] === 'runtimeEnvironments:subscriptionBinary'
)?.[1] as (_event: unknown, args: unknown) => void
const bytes = new Uint8Array([9, 8, 7])
binaryListener({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId, bytes })
expect(sendBinary).toHaveBeenCalledWith(bytes)
const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>(
'runtimeEnvironments:unsubscribe'
)
expect(
await unsubscribe({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId })
).toEqual({
unsubscribed: true
})
expect(close).toHaveBeenCalled()
expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function))
markUsedSpy.mockRestore()
})
it('rejects cross-window streaming subscription control', async () => {
registerRuntimeEnvironmentHandlers()
const close = vi.fn()
const sendBinary = vi.fn()
subscribeRemoteRuntimeRequestMock.mockResolvedValue({
requestId: 'stream-1',
close,
sendBinary
})
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
const subscribe = handler<
{
selector: string
method: string
params?: unknown
subscriptionId?: string
},
{ subscriptionId: string; requestId: string }
>('runtimeEnvironments:subscribe')
const result = await subscribe(
{
sender: {
id: 1,
isDestroyed: () => false,
send: vi.fn(),
once: vi.fn(),
removeListener: vi.fn()
}
},
{
selector: 'desk',
method: 'terminal.subscribe',
params: { terminal: 't1' },
subscriptionId: 'owned-sub'
}
)
const binaryListener = onMock.mock.calls.find(
(call) => call[0] === 'runtimeEnvironments:subscriptionBinary'
)?.[1] as (_event: unknown, args: unknown) => void
binaryListener(
{ sender: { id: 2 } },
{ subscriptionId: result.subscriptionId, bytes: new Uint8Array([1]) }
)
expect(sendBinary).not.toHaveBeenCalled()
const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>(
'runtimeEnvironments:unsubscribe'
)
expect(
await unsubscribe({ sender: { id: 2 } }, { subscriptionId: result.subscriptionId })
).toEqual({
unsubscribed: false
})
expect(close).not.toHaveBeenCalled()
expect(
await unsubscribe({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId })
).toEqual({
unsubscribed: true
})
expect(close).toHaveBeenCalled()
})
it('closes a streaming subscription that resolves after the sender is destroyed', async () => {
registerRuntimeEnvironmentHandlers()
const close = vi.fn()
let resolveSubscribe: (value: {
requestId: string
close: () => void
sendBinary: (bytes: Uint8Array<ArrayBufferLike>) => boolean
}) => void = () => {}
subscribeRemoteRuntimeRequestMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveSubscribe = resolve
})
)
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
let destroyed = false
let destroyedHandler: unknown = null
const destroyedListenerRemoved = vi.fn()
const subscribe = handler<
{
selector: string
method: string
params?: unknown
subscriptionId?: string
},
{ subscriptionId: string; requestId: string }
>('runtimeEnvironments:subscribe')
const resultPromise = subscribe(
{
sender: {
id: 1,
isDestroyed: () => destroyed,
send: vi.fn(),
once: vi.fn((_event: string, handler: () => void) => {
destroyedHandler = () => {
destroyed = true
handler()
}
}),
removeListener: destroyedListenerRemoved
}
},
{
selector: 'desk',
method: 'terminal.subscribe',
params: { terminal: 't1' },
subscriptionId: 'late-sub'
}
)
await vi.waitFor(() => {
expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalled()
})
expect(destroyedHandler).toBeTypeOf('function')
;(destroyedHandler as () => void)()
resolveSubscribe({ requestId: 'stream-late', close, sendBinary: vi.fn() })
await expect(resultPromise).resolves.toEqual({
subscriptionId: 'late-sub',
requestId: 'stream-late'
})
expect(close).toHaveBeenCalledTimes(1)
expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function))
const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>(
'runtimeEnvironments:unsubscribe'
)
expect(await unsubscribe({ sender: { id: 1 } }, { subscriptionId: 'late-sub' })).toEqual({
unsubscribed: false
})
})
it('removes the destroyed listener when streaming subscription setup rejects', async () => {
registerRuntimeEnvironmentHandlers()
subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('connect failed'))
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
const destroyedListenerRemoved = vi.fn()
const subscribe = handler<
{
selector: string
method: string
params?: unknown
subscriptionId?: string
},
{ subscriptionId: string; requestId: string }
>('runtimeEnvironments:subscribe')
await expect(
subscribe(
{
sender: {
id: 1,
isDestroyed: () => false,
send: vi.fn(),
once: vi.fn(),
removeListener: destroyedListenerRemoved
}
},
{
selector: 'desk',
method: 'terminal.subscribe',
params: { terminal: 't1' },
subscriptionId: 'failed-sub'
}
)
).rejects.toThrow('connect failed')
expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function))
})
})
+313
View File
@@ -0,0 +1,313 @@
/* eslint-disable max-lines -- Why: runtime environment IPC is the security boundary for saved server calls and subscriptions; keeping ownership checks, lifecycle cleanup, and binary forwarding together makes the bridge auditable. */
import { app, ipcMain } from 'electron'
import { randomUUID } from 'crypto'
import {
addEnvironmentFromPairingCode,
listEnvironments,
markEnvironmentUsed,
removeEnvironment,
resolveEnvironment,
resolveEnvironmentPairingOffer
} from '../../shared/runtime-environment-store'
import {
redactRuntimeEnvironment,
getPreferredPairingOffer,
type PublicKnownRuntimeEnvironment
} from '../../shared/runtime-environments'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import {
sendRemoteRuntimeRequest,
subscribeRemoteRuntimeRequest,
type RemoteRuntimeSubscription
} from '../../shared/remote-runtime-client'
import { enqueueRuntimeCall } from './runtime-environment-call-queue'
import {
closeRemoteRuntimeRequestConnection,
sendRemoteRuntimeConnectionRequest
} from './runtime-environment-request-connections'
const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000
type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & {
ownerWebContentsId: number
removeDestroyedListener: () => void
}
const remoteRuntimeSubscriptions = new Map<string, RetainedRemoteRuntimeSubscription>()
function getUserDataPath(): string {
return app.getPath('userData')
}
function shouldUseCachedRequestConnection(method: string): boolean {
return method === 'terminal.send' || method === 'terminal.updateViewport'
}
export function registerRuntimeEnvironmentHandlers(): void {
ipcMain.handle('runtimeEnvironments:list', (): PublicKnownRuntimeEnvironment[] =>
listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
)
ipcMain.handle(
'runtimeEnvironments:addFromPairingCode',
(
_event,
args: { name: string; pairingCode: string }
): { environment: PublicKnownRuntimeEnvironment } => ({
environment: redactRuntimeEnvironment(addEnvironmentFromPairingCode(getUserDataPath(), args))
})
)
ipcMain.handle(
'runtimeEnvironments:resolve',
(_event, args: { selector: string }): PublicKnownRuntimeEnvironment =>
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
)
ipcMain.handle(
'runtimeEnvironments:remove',
(_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => {
const removed = removeEnvironment(getUserDataPath(), args.selector)
closeRemoteRuntimeRequestConnection(removed.id)
if (args.selector !== removed.id) {
closeRemoteRuntimeRequestConnection(args.selector)
}
return { removed: redactRuntimeEnvironment(removed) }
}
)
ipcMain.handle(
'runtimeEnvironments:getStatus',
async (
_event,
args: { selector: string; timeoutMs?: number }
): Promise<RuntimeRpcResponse<RuntimeStatus>> => {
const userDataPath = getUserDataPath()
const response = await sendRemoteRuntimeRequest<RuntimeStatus>(
resolveEnvironmentPairingOffer(userDataPath, args.selector),
'status.get',
undefined,
args.timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
)
if (response.ok === true) {
markEnvironmentUsed(userDataPath, args.selector, { runtimeId: response._meta.runtimeId })
}
return response
}
)
ipcMain.handle(
'runtimeEnvironments:call',
async (
_event,
args: { selector: string; method: string; params?: unknown; timeoutMs?: number }
): Promise<RuntimeRpcResponse<unknown>> => {
return callRuntimeEnvironment(args.selector, args.method, args.params, args.timeoutMs)
}
)
ipcMain.handle(
'runtimeEnvironments:subscribe',
async (
event,
args: {
selector: string
method: string
params?: unknown
timeoutMs?: number
subscriptionId?: string
}
): Promise<{ subscriptionId: string; requestId: string }> => {
const subscriptionId =
typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0
? args.subscriptionId
: randomUUID()
if (remoteRuntimeSubscriptions.has(subscriptionId)) {
throw new Error('Runtime environment subscription id already exists')
}
const sender = event.sender
const ownerWebContentsId = sender.id
let senderDestroyed = sender.isDestroyed()
let subscription: RemoteRuntimeSubscription | null = null
let destroyedListenerAttached = false
const removeDestroyedListener = (): void => {
if (!destroyedListenerAttached) {
return
}
destroyedListenerAttached = false
sender.removeListener('destroyed', closeSubscription)
}
const closeSubscription = (): void => {
senderDestroyed = true
const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null
remoteRuntimeSubscriptions.delete(subscriptionId)
if (retained) {
retained.close()
return
}
removeDestroyedListener()
subscription?.close()
}
sender.once('destroyed', closeSubscription)
destroyedListenerAttached = true
try {
subscription = await subscribeRuntimeEnvironment(
args.selector,
args.method,
args.params,
args.timeoutMs,
{
onEvent: (payload) => {
if (!sender.isDestroyed()) {
sender.send('runtimeEnvironments:subscriptionEvent', {
subscriptionId,
...payload
})
}
},
onClose: () => {
const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null
retained?.removeDestroyedListener()
remoteRuntimeSubscriptions.delete(subscriptionId)
}
}
)
} catch (error) {
removeDestroyedListener()
throw error
}
if (senderDestroyed || sender.isDestroyed()) {
removeDestroyedListener()
subscription.close()
return { subscriptionId, requestId: subscription.requestId }
}
remoteRuntimeSubscriptions.set(subscriptionId, {
requestId: subscription.requestId,
ownerWebContentsId,
removeDestroyedListener,
sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false,
close: () => {
removeDestroyedListener()
subscription?.close()
}
})
return { subscriptionId, requestId: subscription.requestId }
}
)
ipcMain.handle(
'runtimeEnvironments:unsubscribe',
(event, args: { subscriptionId: string }): { unsubscribed: boolean } => {
const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId)
if (!subscription || subscription.ownerWebContentsId !== event.sender.id) {
return { unsubscribed: false }
}
remoteRuntimeSubscriptions.delete(args.subscriptionId)
subscription.close()
return { unsubscribed: true }
}
)
ipcMain.on(
'runtimeEnvironments:subscriptionBinary',
(event, args: { subscriptionId?: unknown; bytes?: unknown }) => {
if (typeof args.subscriptionId !== 'string') {
return
}
const bytes = toBinaryPayload(args.bytes)
if (!bytes) {
return
}
const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId)
if (subscription?.ownerWebContentsId === event.sender.id) {
subscription.sendBinary(bytes)
}
}
)
}
function toBinaryPayload(value: unknown): Uint8Array<ArrayBufferLike> | null {
if (value instanceof Uint8Array) {
return value
}
if (value instanceof ArrayBuffer) {
return new Uint8Array(value)
}
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
}
return null
}
async function callRuntimeEnvironment(
selector: string,
method: string,
params: unknown,
timeoutMs?: number
): Promise<RuntimeRpcResponse<unknown>> {
const userDataPath = getUserDataPath()
const environment = resolveEnvironment(userDataPath, selector)
return enqueueRuntimeCall(environment.id, method, async () => {
const currentEnvironment = resolveEnvironment(userDataPath, environment.id)
const pairing = getPreferredPairingOffer(currentEnvironment)
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
// Why: the cached request socket is only needed for terminal hot paths.
// Startup/control-plane RPCs use the proven one-shot path so repo hydration
// cannot be coupled to a stale terminal-control connection.
const response = shouldUseCachedRequestConnection(method)
? await sendRemoteRuntimeConnectionRequest(
currentEnvironment.id,
pairing,
method,
params,
effectiveTimeoutMs
)
: await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs)
if (response.ok === true) {
markEnvironmentUsed(userDataPath, currentEnvironment.id, {
runtimeId: response._meta.runtimeId
})
}
return response
})
}
async function subscribeRuntimeEnvironment(
selector: string,
method: string,
params: unknown,
timeoutMs: number | undefined,
callbacks: {
onEvent: (
payload:
| { type: 'response'; response: RuntimeRpcResponse<unknown> }
| { type: 'binary'; bytes: Uint8Array<ArrayBufferLike> }
| { type: 'error'; code: string; message: string }
| { type: 'close' }
) => void
onClose: () => void
}
): Promise<RemoteRuntimeSubscription> {
const userDataPath = getUserDataPath()
let markedUsed = false
const markUsedOnce = (runtimeId: string): void => {
if (markedUsed) {
return
}
markedUsed = true
markEnvironmentUsed(userDataPath, selector, { runtimeId })
}
const subscription = await subscribeRemoteRuntimeRequest(
resolveEnvironmentPairingOffer(userDataPath, selector),
method,
params,
timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS,
{
onResponse: (response) => {
if (response.ok === true) {
markUsedOnce(response._meta.runtimeId)
}
callbacks.onEvent({ type: 'response', response })
},
onBinary: (bytes) => callbacks.onEvent({ type: 'binary', bytes }),
onError: (error) =>
callbacks.onEvent({ type: 'error', code: error.code, message: error.message }),
onClose: () => {
callbacks.onEvent({ type: 'close' })
callbacks.onClose()
}
}
)
return subscription
}
+31 -1
View File
@@ -28,7 +28,8 @@ describe('registerRuntimeHandlers', () => {
it('routes sync requests through the authoritative browser window id', () => {
const runtime = {
syncWindowGraph: vi.fn().mockReturnValue({ graphStatus: 'ready' }),
getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' })
getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' }),
getRuntimeId: vi.fn().mockReturnValue('runtime-1')
}
registerRuntimeHandlers(runtime as never)
@@ -46,4 +47,33 @@ describe('registerRuntimeHandlers', () => {
expect(runtime.syncWindowGraph).toHaveBeenCalledWith(17, { tabs: [], leaves: [] })
expect(result).toEqual({ graphStatus: 'ready' })
})
it('routes generic local runtime RPC calls through the dispatcher', async () => {
const runtime = {
syncWindowGraph: vi.fn(),
getStatus: vi.fn().mockReturnValue({
runtimeId: 'runtime-1',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0
}),
getRuntimeId: vi.fn().mockReturnValue('runtime-1')
}
registerRuntimeHandlers(runtime as never)
const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')
expect(callRegistration).toBeTruthy()
const handler = callRegistration![1]
const result = await handler({ sender: {} }, { method: 'status.get' })
expect(result).toMatchObject({
ok: true,
result: { runtimeId: 'runtime-1', graphStatus: 'ready' },
_meta: { runtimeId: 'runtime-1' }
})
})
})
+18
View File
@@ -1,10 +1,13 @@
import { BrowserWindow, ipcMain } from 'electron'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
ipcMain.removeHandler('runtime:syncWindowGraph')
ipcMain.removeHandler('runtime:getStatus')
ipcMain.removeHandler('runtime:call')
ipcMain.handle(
'runtime:syncWindowGraph',
@@ -21,6 +24,21 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
return runtime.getStatus()
})
ipcMain.handle(
'runtime:call',
async (
_event,
args: { method: string; params?: unknown }
): Promise<RuntimeRpcResponse<unknown>> => {
return (await new RpcDispatcher({ runtime }).dispatch({
id: 'desktop-ipc',
authToken: 'desktop-ipc',
method: args.method,
params: args.params
})) as RuntimeRpcResponse<unknown>
}
)
ipcMain.removeHandler('runtime:getTerminalFitOverrides')
ipcMain.handle(
'runtime:getTerminalFitOverrides',
+2 -2
View File
@@ -108,7 +108,7 @@ async function ensureUniqueRemoteName(repoPath: string, preferred: string): Prom
throw new Error(`Could not find an available remote name for ${preferred}.`)
}
async function prepareWorktreePushTarget(
export async function prepareWorktreePushTarget(
repoPath: string,
target: GitPushTarget
): Promise<GitPushTarget> {
@@ -138,7 +138,7 @@ async function prepareWorktreePushTarget(
}
}
async function configureCreatedWorktreePushTarget(
export async function configureCreatedWorktreePushTarget(
worktreePath: string,
branchName: string,
target: GitPushTarget
+7 -4
View File
@@ -13,10 +13,13 @@ describe('PowerShell OSC 133 bootstrap', () => {
expect(script).toContain('ORCA_PI_CODING_AGENT_DIR')
expect(script).toContain('function Global:prompt')
expect(script).toContain('function Global:PSConsoleHostReadLine')
expect(script).toContain('`e]133;D;$fakeExitCode`a')
expect(script).toContain('`e]133;A`a')
expect(script).toContain('`e]133;B`a')
expect(script).toContain('`e]133;C`a')
expect(script).toContain('Esc = [char]27')
expect(script).toContain('Bel = [char]7')
expect(script).toContain(')]133;D;$fakeExitCode$(')
expect(script).toContain(')]133;A$(')
expect(script).toContain(')]133;B$(')
expect(script).toContain(')]133;C$(')
expect(script).not.toContain('`e]133')
expect(script).not.toContain('$PROFILE')
expect(script).not.toContain('ExecutionPolicy')
expect(script).not.toContain('NoProfile')
+6 -4
View File
@@ -28,6 +28,8 @@ $Global:__OrcaOsc133State = @{
OriginalReadLine = $function:PSConsoleHostReadLine
HasSeenPrompt = $false
HasPSReadLine = $null -ne (Get-Module -Name PSReadLine)
Esc = [char]27
Bel = [char]7
}
function Global:prompt {
@@ -39,15 +41,15 @@ function Global:prompt {
# Emit D from prompt, not readline state. Some profile setups bypass
# PSConsoleHostReadLine; the consumer only needs completion.
if ($Global:__OrcaOsc133State.HasSeenPrompt) {
$result += "\`e]133;D;$fakeExitCode\`a"
$result += "$($Global:__OrcaOsc133State.Esc)]133;D;$fakeExitCode$($Global:__OrcaOsc133State.Bel)"
}
$Global:__OrcaOsc133State.HasSeenPrompt = $true
$result += "\`e]133;A\`a"
$result += "$($Global:__OrcaOsc133State.Esc)]133;A$($Global:__OrcaOsc133State.Bel)"
# Preserve the previous success/failure value for prompts that inspect it.
if ($fakeExitCode -ne 0) { Write-Error "failure" -ea ignore }
$result += $Global:__OrcaOsc133State.OriginalPrompt.Invoke()
$result += "\`e]133;B\`a"
$result += "$($Global:__OrcaOsc133State.Esc)]133;B$($Global:__OrcaOsc133State.Bel)"
$result
}
@@ -55,7 +57,7 @@ if ($Global:__OrcaOsc133State.HasPSReadLine -and
$null -ne $Global:__OrcaOsc133State.OriginalReadLine) {
function Global:PSConsoleHostReadLine {
$commandLine = $Global:__OrcaOsc133State.OriginalReadLine.Invoke()
[Console]::Write("\`e]133;C\`a")
[Console]::Write("$($Global:__OrcaOsc133State.Esc)]133;C$($Global:__OrcaOsc133State.Bel)")
return $commandLine
}
}
@@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: SSH filesystem provider coverage keeps relay fallback,
SFTP binary writes, watch fan-out, and provider lifecycle tests together so
transport parity regressions are visible in one suite. */
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshFilesystemProvider } from './ssh-filesystem-provider'
@@ -90,6 +93,64 @@ describe('SshFilesystemProvider', () => {
})
})
describe('writeFileBase64', () => {
it('writes decoded bytes through SFTP', async () => {
const written: Buffer[] = []
const writeStream = {
on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream),
end: vi.fn((buffer: Buffer) => {
written.push(buffer)
const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1]
closeHandler?.()
}),
destroy: vi.fn()
}
const sftp = {
createWriteStream: vi.fn(() => writeStream),
end: vi.fn()
}
provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never)
await provider.writeFileBase64('/home/user/logo.png', 'cG5n')
expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'wx' })
expect(written).toEqual([Buffer.from('png')])
expect(sftp.end).toHaveBeenCalled()
expect(mux.request).not.toHaveBeenCalledWith('fs.writeFile', expect.anything())
})
it('can append decoded chunks through SFTP', async () => {
const writeStream = {
on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream),
end: vi.fn((_buffer: Buffer) => {
const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1]
closeHandler?.()
}),
destroy: vi.fn()
}
const sftp = {
createWriteStream: vi.fn(() => writeStream),
end: vi.fn()
}
provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never)
await provider.writeFileBase64Chunk('/home/user/logo.png', 'cG5n', true)
expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'a' })
expect(sftp.end).toHaveBeenCalled()
})
})
describe('createDirNoClobber', () => {
it('sends fs.createDirNoClobber request', async () => {
await provider.createDirNoClobber('/home/user/new-dir')
expect(mux.request).toHaveBeenCalledWith('fs.createDirNoClobber', {
dirPath: '/home/user/new-dir'
})
})
})
describe('stat', () => {
it('sends fs.stat request', async () => {
const statResult = { size: 1024, type: 'file', mtime: 1234567890 }
@@ -196,6 +257,112 @@ describe('SshFilesystemProvider', () => {
expect(callback).toHaveBeenCalledWith(events)
})
it('fans out same-root watch events and unwatches only after the last subscriber', async () => {
const first = vi.fn()
const second = vi.fn()
const unsubFirst = await provider.watch('/home/user/project', first)
const unsubSecond = await provider.watch('/home/user/project', second)
expect(mux.request).toHaveBeenCalledTimes(1)
expect(mux.request).toHaveBeenCalledWith('fs.watch', { rootPath: '/home/user/project' })
const notifHandler = mux.onNotification.mock.calls[0][0]
const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }]
notifHandler('fs.changed', { events })
expect(first).toHaveBeenCalledWith(events)
expect(second).toHaveBeenCalledWith(events)
unsubFirst()
expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
notifHandler('fs.changed', { events })
expect(second).toHaveBeenCalledTimes(2)
unsubSecond()
expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
})
it('shares an in-flight same-root watch setup across concurrent subscribers', async () => {
let resolveWatch: () => void = () => {}
mux.request.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveWatch = resolve
})
)
const first = vi.fn()
const second = vi.fn()
const firstWatch = provider.watch('/home/user/project', first)
const secondWatch = provider.watch('/home/user/project', second)
expect(mux.request).toHaveBeenCalledTimes(1)
resolveWatch()
const [unsubFirst, unsubSecond] = await Promise.all([firstWatch, secondWatch])
const notifHandler = mux.onNotification.mock.calls[0][0]
const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }]
notifHandler('fs.changed', { events })
expect(first).toHaveBeenCalledWith(events)
expect(second).toHaveBeenCalledWith(events)
unsubFirst()
expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
unsubSecond()
expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
})
it('does not retain a watch listener when fs.watch setup fails', async () => {
mux.request.mockRejectedValueOnce(new Error('watch unavailable'))
const first = vi.fn()
await expect(provider.watch('/home/user/project', first)).rejects.toThrow('watch unavailable')
const second = vi.fn()
await provider.watch('/home/user/project', second)
const notifHandler = mux.onNotification.mock.calls[0][0]
const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }]
notifHandler('fs.changed', { events })
expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledWith(events)
})
it('does not forward sibling paths with matching prefixes', async () => {
const callback = vi.fn()
await provider.watch('/home/user/project', callback)
const notifHandler = mux.onNotification.mock.calls[0][0]
notifHandler('fs.changed', {
events: [
{ kind: 'update', absolutePath: '/home/user/project-old/file.ts' },
{ kind: 'update', absolutePath: '/home/user/project2/file.ts' }
]
})
expect(callback).not.toHaveBeenCalled()
})
it('matches Windows and UNC watch roots case-insensitively', async () => {
const driveCallback = vi.fn()
const uncCallback = vi.fn()
await provider.watch('C:\\Repo', driveCallback)
await provider.watch('//Server/Share/Repo', uncCallback)
const notifHandler = mux.onNotification.mock.calls[0][0]
notifHandler('fs.changed', {
events: [
{ kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' },
{ kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' }
]
})
expect(driveCallback).toHaveBeenCalledWith([
{ kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' }
])
expect(uncCallback).toHaveBeenCalledWith([
{ kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' }
])
})
it('sends fs.unwatch when last listener unsubscribes', async () => {
const callback = vi.fn()
const unsub = await provider.watch('/home/user/project', callback)
+82 -12
View File
@@ -1,7 +1,16 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader'
import { uploadBuffer } from '../ssh/sftp-upload'
import type { IFilesystemProvider, FileStat, FileReadResult } from './types'
import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import type { SFTPWrapper } from 'ssh2'
type SftpFactory = () => Promise<SFTPWrapper>
type WatchRegistration = {
callbacks: Set<(events: FsChangeEvent[]) => void>
setupPromise: Promise<void>
}
export class SshFilesystemProvider implements IFilesystemProvider {
private connectionId: string
@@ -9,7 +18,7 @@ export class SshFilesystemProvider implements IFilesystemProvider {
// Why: each watch() call registers for a specific rootPath, but the relay
// sends all fs.changed events on one notification channel. Keying by rootPath
// prevents cross-pollination between different worktree watchers.
private watchListeners = new Map<string, (events: FsChangeEvent[]) => void>()
private watchListeners = new Map<string, WatchRegistration>()
// Why: store the unsubscribe handle so dispose() can detach from the
// multiplexer. Without this, notification callbacks keep firing after
// the provider is torn down on disconnect, routing events to stale state.
@@ -19,17 +28,23 @@ export class SshFilesystemProvider implements IFilesystemProvider {
// relays get diagnosed quickly without per-read log spam.
private loggedStreamFallback = false
constructor(connectionId: string, mux: SshChannelMultiplexer) {
constructor(
connectionId: string,
mux: SshChannelMultiplexer,
private readonly createSftp?: SftpFactory
) {
this.connectionId = connectionId
this.mux = mux
this.unsubscribeNotifications = mux.onNotification((method, params) => {
if (method === 'fs.changed') {
const events = params.events as FsChangeEvent[]
for (const [rootPath, cb] of this.watchListeners) {
const matching = events.filter((e) => e.absolutePath.startsWith(rootPath))
for (const [rootPath, registration] of this.watchListeners) {
const matching = events.filter((e) => isPathInsideOrEqual(rootPath, e.absolutePath))
if (matching.length > 0) {
cb(matching)
for (const cb of registration.callbacks) {
cb(matching)
}
}
}
}
@@ -78,6 +93,31 @@ export class SshFilesystemProvider implements IFilesystemProvider {
await this.mux.request('fs.writeFile', { filePath, content })
}
async writeFileBase64(filePath: string, contentBase64: string): Promise<void> {
await this.writeFileBase64Chunk(filePath, contentBase64, false)
}
async writeFileBase64Chunk(
filePath: string,
contentBase64: string,
append: boolean
): Promise<void> {
if (!this.createSftp) {
throw new Error('remote_binary_upload_unavailable')
}
const sftp = await this.createSftp()
try {
// Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes
// directly so runtime uploads do not corrupt images, PDFs, or archives.
await uploadBuffer(sftp, Buffer.from(contentBase64, 'base64'), filePath, {
append,
exclusive: !append
})
} finally {
sftp.end()
}
}
async stat(filePath: string): Promise<FileStat> {
return (await this.mux.request('fs.stat', { filePath })) as FileStat
}
@@ -94,6 +134,10 @@ export class SshFilesystemProvider implements IFilesystemProvider {
await this.mux.request('fs.createDir', { dirPath })
}
async createDirNoClobber(dirPath: string): Promise<void> {
await this.mux.request('fs.createDirNoClobber', { dirPath })
}
async rename(oldPath: string, newPath: string): Promise<void> {
await this.mux.request('fs.rename', { oldPath, newPath })
}
@@ -122,15 +166,41 @@ export class SshFilesystemProvider implements IFilesystemProvider {
}
async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> {
this.watchListeners.set(rootPath, callback)
await this.mux.request('fs.watch', { rootPath })
let registration = this.watchListeners.get(rootPath)
if (registration) {
registration.callbacks.add(callback)
await registration.setupPromise
return this.createWatchUnsubscribe(rootPath, registration, callback)
}
const callbacks = new Set<(events: FsChangeEvent[]) => void>([callback])
const setupPromise = this.mux.request('fs.watch', { rootPath }).then(
() => undefined,
(error) => {
if (this.watchListeners.get(rootPath) === registration) {
this.watchListeners.delete(rootPath)
}
throw error
}
)
registration = { callbacks, setupPromise }
this.watchListeners.set(rootPath, registration)
await setupPromise
return this.createWatchUnsubscribe(rootPath, registration, callback)
}
private createWatchUnsubscribe(
rootPath: string,
registration: WatchRegistration,
callback: (events: FsChangeEvent[]) => void
): () => void {
return () => {
this.watchListeners.delete(rootPath)
// Why: each watch() starts a @parcel/watcher on the relay for this specific
// rootPath. We must always notify the relay to stop it, not only when all
// watchers are gone — otherwise the remote watcher leaks inotify descriptors.
this.mux.notify('fs.unwatch', { rootPath })
registration.callbacks.delete(callback)
if (registration.callbacks.size === 0 && this.watchListeners.get(rootPath) === registration) {
this.watchListeners.delete(rootPath)
this.mux.notify('fs.unwatch', { rootPath })
}
}
}
}
+3
View File
@@ -118,10 +118,13 @@ export type IFilesystemProvider = {
readDir(dirPath: string): Promise<DirEntry[]>
readFile(filePath: string): Promise<FileReadResult>
writeFile(filePath: string, content: string): Promise<void>
writeFileBase64(filePath: string, contentBase64: string): Promise<void>
writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise<void>
stat(filePath: string): Promise<FileStat>
deletePath(targetPath: string, recursive?: boolean): Promise<void>
createFile(filePath: string): Promise<void>
createDir(dirPath: string): Promise<void>
createDirNoClobber(dirPath: string): Promise<void>
rename(oldPath: string, newPath: string): Promise<void>
copy(source: string, destination: string): Promise<void>
realpath(filePath: string): Promise<string>
@@ -41,8 +41,11 @@ describe('resolveWindowsShellLaunchArgs', () => {
expect(opencodeRestoreIndex).toBeGreaterThan(outputEncodingIndex)
expect(piRestoreIndex).toBeGreaterThan(outputEncodingIndex)
expect(promptIndex).toBeGreaterThan(piRestoreIndex)
expect(command).toContain('`e]133;D;$fakeExitCode`a')
expect(command).toContain('`e]133;C`a')
expect(command).toContain('Esc = [char]27')
expect(command).toContain('Bel = [char]7')
expect(command).toContain(')]133;D;$fakeExitCode$(')
expect(command).toContain(')]133;C$(')
expect(command).not.toContain('`e]133')
})
it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => {
+8
View File
@@ -1,6 +1,7 @@
import type { GitWorktreeInfo, Repo } from '../shared/types'
import { listWorktrees } from './git/worktree'
import { isFolderRepo } from '../shared/repo-kind'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
export function createFolderWorktree(repo: Repo): GitWorktreeInfo {
return {
@@ -19,5 +20,12 @@ export async function listRepoWorktrees(repo: Repo): Promise<GitWorktreeInfo[]>
if (isFolderRepo(repo)) {
return [createFolderWorktree(repo)]
}
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
// Why: runtime worktree resolution can run before SSH providers have
// reattached during startup. Return empty instead of falling back to
// local git against a server path.
return provider ? await provider.listWorktrees(repo.path) : []
}
return await listWorktrees(repo.path)
}
+26 -11
View File
@@ -3,15 +3,19 @@
// 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 { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file'
const DEVICE_REGISTRY_FILENAME = 'orca-devices.json'
export type DeviceScope = 'mobile' | 'runtime'
export type DeviceEntry = {
deviceId: string
name: string
token: string
scope: DeviceScope
pairedAt: number
lastSeenAt: number
}
@@ -25,11 +29,12 @@ export class DeviceRegistry {
this.load()
}
addDevice(name: string): DeviceEntry {
addDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
const entry: DeviceEntry = {
deviceId: randomUUID(),
name,
token: randomBytes(24).toString('hex'),
scope,
pairedAt: Date.now(),
lastSeenAt: 0
}
@@ -44,12 +49,12 @@ export class DeviceRegistry {
// copy-button flow that encourages regeneration) leaves an orphaned token
// forever. Returns an existing never-scanned entry if present; otherwise
// mints a new one and drops any stale pending entries.
getOrCreatePendingDevice(name: string): DeviceEntry {
const existing = this.devices.find((d) => d.lastSeenAt === 0)
getOrCreatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
const existing = this.devices.find((d) => d.lastSeenAt === 0 && d.scope === scope)
if (existing) {
return existing
}
return this.addDevice(name)
return this.addDevice(name, scope)
}
// Why: explicit rotation path for "Regenerate QR" — invalidates any
@@ -58,9 +63,9 @@ export class DeviceRegistry {
// this, getOrCreatePendingDevice keeps returning the same token forever
// until a phone actually pairs, so users have no way to revoke a leaked
// pre-pairing token.
rotatePendingDevice(name: string): DeviceEntry {
this.devices = this.devices.filter((d) => d.lastSeenAt !== 0)
return this.addDevice(name)
rotatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
this.devices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope)
return this.addDevice(name, scope)
}
removeDevice(deviceId: string): boolean {
@@ -73,6 +78,10 @@ export class DeviceRegistry {
return false
}
getDevice(deviceId: string): DeviceEntry | null {
return this.devices.find((d) => d.deviceId === deviceId) ?? null
}
listDevices(): readonly DeviceEntry[] {
return this.devices
}
@@ -95,14 +104,20 @@ export class DeviceRegistry {
return
}
try {
this.devices = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[]
hardenExistingSecureFile(this.registryPath)
const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[]
this.devices = parsed.map((device) => ({
...device,
// Why: older registries only existed for phone pairing. Treat missing
// scope as mobile so legacy device tokens do not gain new CLI powers.
scope: device.scope === 'runtime' ? 'runtime' : 'mobile'
}))
} catch {
this.devices = []
}
}
private save(): void {
writeFileSync(this.registryPath, JSON.stringify(this.devices, null, 2), { mode: 0o600 })
chmodSync(this.registryPath, 0o600)
writeSecureJsonFile(this.registryPath, this.devices)
}
}
+4 -3
View File
@@ -1,9 +1,10 @@
// Why: the E2EE keypair enables application-layer encryption between mobile
// and desktop over plain ws://. The public key is embedded in the QR pairing
// offer so the mobile client can derive a shared secret via ECDH.
import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import nacl from 'tweetnacl'
import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file'
const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json'
const KEYPAIR_VERSION = 1
@@ -25,6 +26,7 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair {
if (existsSync(filePath)) {
try {
hardenExistingSecureFile(filePath)
const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8'))
if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) {
const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64'))
@@ -43,8 +45,7 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair {
const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64')
const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 }
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
chmodSync(filePath, 0o600)
writeSecureJsonFile(filePath, data)
return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 }
}
@@ -295,6 +295,27 @@ describe('mobile presence lock — multi-mobile semantics', () => {
expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true)
})
it('updateDesktopViewport resizes the source PTY and records desktop geometry', async () => {
const { runtime, ptySizes, resizes } = createRuntime()
expect(await runtime.updateDesktopViewport('pty-1', { cols: 132, rows: 44 })).toBe(true)
expect(ptySizes.get('pty-1')).toEqual({ cols: 132, rows: 44 })
expect(resizes.at(-1)).toEqual({ ptyId: 'pty-1', cols: 132, rows: 44 })
expect(runtime.getLastRendererSize('pty-1')).toEqual({ cols: 132, rows: 44 })
})
it('updateDesktopViewport does not resize while mobile is driving', async () => {
const { runtime, ptySizes, resizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 20 })
resizes.length = 0
expect(await runtime.updateDesktopViewport('pty-1', { cols: 132, rows: 44 })).toBe(false)
expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 20 })
expect(resizes).toEqual([])
})
it('updateMobileViewport then disconnect restores PTY to original baseline', async () => {
const { runtime, ptySizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 })
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as Fs from 'fs'
import type * as FsPromises from 'fs/promises'
import type * as FilesystemAuth from '../ipc/filesystem-auth'
const { resolveAuthorizedPathMock, statMock, watchMock } = vi.hoisted(() => ({
resolveAuthorizedPathMock: vi.fn(),
statMock: vi.fn(),
watchMock: vi.fn()
}))
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof Fs>('fs')
return {
...actual,
watch: watchMock
}
})
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
return {
...actual,
stat: statMock
}
})
vi.mock('../ipc/filesystem-auth', async () => {
const actual = await vi.importActual<typeof FilesystemAuth>('../ipc/filesystem-auth')
return {
...actual,
resolveAuthorizedPath: resolveAuthorizedPathMock
}
})
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn()
}))
import { RuntimeFileCommands } from './orca-runtime-files'
describe('RuntimeFileCommands', () => {
const originalPlatform = process.platform
beforeEach(() => {
vi.useFakeTimers()
resolveAuthorizedPathMock.mockReset()
statMock.mockReset()
watchMock.mockReset()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
})
afterEach(() => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
vi.useRealTimers()
})
it('uses a conservative Node watcher for Windows runtime file watches', async () => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const store = { getRepo: vi.fn(() => undefined) }
const close = vi.fn()
const on = vi.fn()
let listener: (() => void) | null = null
watchMock.mockImplementation((_rootPath, _options, callback) => {
listener = callback
return { close, on }
})
resolveAuthorizedPathMock.mockResolvedValue('C:\\repo')
statMock.mockResolvedValue({ isDirectory: () => true })
const commands = new RuntimeFileCommands({
getRuntimeId: () => 'runtime-1',
requireStore: () => store,
resolveWorktreeSelector: vi.fn(async () => ({
id: 'wt-1',
repoId: 'repo-1',
path: 'C:\\repo'
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
} as never)
const onEvents = vi.fn()
const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents)
expect(watchMock).toHaveBeenCalledWith('C:\\repo', { recursive: true }, expect.any(Function))
const emit = listener as (() => void) | null
expect(emit).not.toBeNull()
emit?.()
emit?.()
await vi.advanceTimersByTimeAsync(149)
expect(onEvents).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(onEvents).toHaveBeenCalledTimes(1)
expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: 'C:\\repo' }])
unsubscribe()
expect(close).toHaveBeenCalledTimes(1)
})
})
+918
View File
@@ -0,0 +1,918 @@
/* eslint-disable max-lines -- Why: filesystem, editor-file, and search commands share the same local/SSH path authorization rules. Keeping that IO adapter together prevents separate command paths from drifting on safety checks. */
import type { ChildProcess } from 'child_process'
import { watch as watchFs } from 'fs'
import {
constants,
copyFile,
lstat,
mkdir,
open,
readFile,
readdir,
rename,
rm,
stat,
writeFile
} from 'fs/promises'
import { basename, dirname, extname, join } from 'path'
import type {
DirEntry,
FsChangeEvent,
GitWorktreeInfo,
MarkdownDocument,
SearchOptions,
SearchResult,
Worktree
} from '../../shared/types'
import type {
RuntimeFileListResult,
RuntimeFileOpenResult,
RuntimeFilePreviewResult,
RuntimeFileReadResult
} from '../../shared/runtime-types'
import { wslAwareSpawn } from '../git/runner'
import { parseWslPath, toWindowsWslPath } from '../wsl'
import { isENOENT, resolveAuthorizedPath } from '../ipc/filesystem-auth'
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
import { searchWithGitGrep } from '../ipc/filesystem-search-git'
import { checkRgAvailable } from '../ipc/rg-availability'
import {
listMarkdownDocuments,
markdownDocumentsFromRelativePaths
} from '../ipc/markdown-documents'
import {
buildRgArgs,
createAccumulator,
DEFAULT_SEARCH_MAX_RESULTS,
finalize,
ingestRgJsonLine,
SEARCH_TIMEOUT_MS
} from '../../shared/text-search'
import type { Store } from '../persistence'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths'
const MOBILE_FILE_LIST_LIMIT = 5000
const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024
const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024
const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150
const MOBILE_BINARY_EXTENSIONS = new Set([
'.avif',
'.bmp',
'.gif',
'.heic',
'.ico',
'.jpeg',
'.jpg',
'.mov',
'.mp3',
'.mp4',
'.pdf',
'.png',
'.webp',
'.zip'
])
const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf'
}
export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo }
export type RuntimeFileCommandHost = {
getRuntimeId(): string
requireStore(): Store
resolveWorktreeSelector(selector: string): Promise<ResolvedRuntimeFileWorktree>
resolveRuntimeGitTarget(
selector: string
): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }>
openFile(worktreeId: string, filePath: string, relativePath: string): void
}
export class RuntimeFileCommands {
private activeRuntimeTextSearches = new Map<string, ChildProcess>()
constructor(private readonly host: RuntimeFileCommandHost) {}
async listMobileFiles(worktreeSelector: string): Promise<RuntimeFileListResult> {
const store = this.host.requireStore()
const worktree = await this.host.resolveWorktreeSelector(worktreeSelector)
const repo = store.getRepo(worktree.repoId)
const connectionId = repo?.connectionId ?? undefined
const files = connectionId
? await this.listRemoteMobileFiles(worktree.path, connectionId)
: await listQuickOpenFiles(worktree.path, store)
const entries = files
.filter((relativePath) => isSafeMobileRelativePath(relativePath))
.sort((a, b) => a.localeCompare(b))
.slice(0, MOBILE_FILE_LIST_LIMIT)
.map((relativePath) => ({
relativePath,
basename: basenameFromRelativePath(relativePath),
kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const)
}))
return {
worktree: worktree.id,
rootPath: worktree.path,
files: entries,
totalCount: files.length,
truncated: files.length > MOBILE_FILE_LIST_LIMIT
}
}
async openMobileFile(
worktreeSelector: string,
relativePath: string
): Promise<RuntimeFileOpenResult> {
const worktree = await this.host.resolveWorktreeSelector(worktreeSelector)
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
const kind = isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
if (kind === 'binary') {
return { worktree: worktree.id, relativePath, kind, opened: false }
}
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
this.host.openFile(worktree.id, filePath, relativePath)
return { worktree: worktree.id, relativePath, kind, opened: true }
}
async readMobileFile(
worktreeSelector: string,
relativePath: string
): Promise<RuntimeFileReadResult> {
const store = this.host.requireStore()
const worktree = await this.host.resolveWorktreeSelector(worktreeSelector)
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
if (isMobileBinaryPath(relativePath)) {
throw new Error('binary_file')
}
const repo = store.getRepo(worktree.repoId)
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
const content = repo?.connectionId
? await this.readRemoteMobileFile(filePath, repo.connectionId)
: await readLocalMobileFile(filePath, store)
const truncated = truncateMobileFilePreview(content)
return {
worktree: worktree.id,
relativePath,
content: truncated.content,
truncated: truncated.truncated,
byteLength: truncated.byteLength
}
}
async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise<DirEntry[]> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
return provider.readDir(target.path)
}
const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
const entries = await readdir(dirPath, { withFileTypes: true })
const mapped = await Promise.all(
entries.map(async (entry) => {
const entryPath = join(dirPath, entry.name)
return {
name: entry.name,
isDirectory: await isRuntimeDirectoryEntry(entryPath),
isSymlink: entry.isSymbolicLink()
}
})
)
return mapped.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
})
}
async watchFileExplorer(
worktreeSelector: string,
callback: (events: FsChangeEvent[]) => void
): Promise<() => void> {
const target = await this.resolveFileExplorerPath(worktreeSelector, '')
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
return provider.watch(target.path, callback)
}
const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
const rootStats = await stat(rootPath)
if (!rootStats.isDirectory()) {
throw new Error('not_a_directory')
}
if (process.platform === 'win32') {
return watchWindowsRuntimeFileExplorer(rootPath, callback)
}
const watcher = await import('@parcel/watcher')
const subscription = await watcher.subscribe(
rootPath,
(err, events) => {
if (err) {
console.error('[runtime-files.watch] watcher error', { rootPath, err })
callback([{ kind: 'overflow', absolutePath: rootPath }])
return
}
void Promise.all(
events.map(async (event): Promise<FsChangeEvent> => {
let isDirectory = false
try {
isDirectory = (await stat(event.path)).isDirectory()
} catch {
isDirectory = false
}
return {
kind: event.type,
absolutePath: event.path,
isDirectory
}
})
).then(callback)
},
{
ignore: [
'.git',
'node_modules',
'dist',
'build',
'.next',
'.cache',
'__pycache__',
'target',
'.venv'
]
}
)
return () => {
void subscription.unsubscribe().catch((err: unknown) => {
console.error('[runtime-files.watch] unsubscribe error', { rootPath, err })
})
}
}
async readFileExplorerPreview(
worktreeSelector: string,
relativePath: string
): Promise<RuntimeFilePreviewResult> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
const fileStats = await provider.stat(target.path)
if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) {
throw new Error('file_too_large')
}
const result = await provider.readFile(target.path)
return result
}
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
const fileStats = await stat(filePath)
const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()]
if (mimeType) {
if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) {
throw new Error('file_too_large')
}
const buffer = await readFile(filePath)
return {
content: buffer.toString('base64'),
isBinary: true,
isImage: true,
mimeType
}
}
if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) {
throw new Error('file_too_large')
}
const buffer = await readFile(filePath)
if (isBinaryBuffer(buffer)) {
return { content: '', isBinary: true }
}
return { content: buffer.toString('utf-8'), isBinary: false }
}
async writeFileExplorerFile(
worktreeSelector: string,
relativePath: string,
content: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
await provider.writeFile(target.path, content)
return { ok: true }
}
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
try {
const fileStats = await lstat(filePath)
if (fileStats.isDirectory()) {
throw new Error('Cannot write to a directory')
}
} catch (error) {
if (!isENOENT(error)) {
throw error
}
}
await writeFile(filePath, content, 'utf-8')
return { ok: true }
}
async writeFileExplorerFileBase64(
worktreeSelector: string,
relativePath: string,
contentBase64: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
const content = Buffer.from(contentBase64, 'base64')
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
await provider.writeFileBase64(target.path, contentBase64)
return { ok: true }
}
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, content, { flag: 'wx' })
return { ok: true }
}
async writeFileExplorerFileBase64Chunk(
worktreeSelector: string,
relativePath: string,
contentBase64: string,
append: boolean
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
const content = Buffer.from(contentBase64, 'base64')
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
await provider.writeFileBase64Chunk(target.path, contentBase64, append)
return { ok: true }
}
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, content, { flag: append ? 'a' : 'wx' })
return { ok: true }
}
async createFileExplorerFile(
worktreeSelector: string,
relativePath: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
await provider.createFile(target.path)
return { ok: true }
}
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
await mkdir(dirname(filePath), { recursive: true })
try {
await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' })
} catch (error) {
rethrowRuntimeFileCreateError(error, filePath)
}
return { ok: true }
}
async createFileExplorerDir(
worktreeSelector: string,
relativePath: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
await provider.createDir(target.path)
return { ok: true }
}
const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
await assertRuntimePathDoesNotExist(dirPath)
await mkdir(dirPath, { recursive: false })
return { ok: true }
}
async createFileExplorerDirNoClobber(
worktreeSelector: string,
relativePath: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
await provider.createDirNoClobber(target.path)
return { ok: true }
}
const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
await mkdir(dirPath, { recursive: false })
return { ok: true }
}
async commitFileExplorerUpload(
worktreeSelector: string,
tempRelativePath: string,
finalRelativePath: string
): Promise<{ ok: true }> {
const tempTarget = await this.resolveFileExplorerPath(worktreeSelector, tempRelativePath)
const finalTarget = await this.resolveFileExplorerPath(worktreeSelector, finalRelativePath)
const provider = tempTarget.connectionId
? getSshFilesystemProvider(tempTarget.connectionId)
: null
if (tempTarget.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
await provider.copy(tempTarget.path, finalTarget.path)
await provider.deletePath(tempTarget.path, false).catch(() => {})
return { ok: true }
}
const store = this.host.requireStore()
const tempPath = await resolveAuthorizedPath(tempTarget.path, store)
const finalPath = await resolveAuthorizedPath(finalTarget.path, store)
await mkdir(dirname(finalPath), { recursive: true })
await copyFile(tempPath, finalPath, constants.COPYFILE_EXCL)
await rm(tempPath, { force: true })
return { ok: true }
}
async renameFileExplorerPath(
worktreeSelector: string,
oldRelativePath: string,
newRelativePath: string
): Promise<{ ok: true }> {
const oldTarget = await this.resolveFileExplorerPath(worktreeSelector, oldRelativePath)
const newTarget = await this.resolveFileExplorerPath(worktreeSelector, newRelativePath)
const provider = oldTarget.connectionId
? getSshFilesystemProvider(oldTarget.connectionId)
: null
if (oldTarget.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
await provider.rename(oldTarget.path, newTarget.path)
return { ok: true }
}
const store = this.host.requireStore()
const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true })
const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true })
await assertRuntimePathDoesNotExist(newPath)
await rename(oldPath, newPath)
return { ok: true }
}
async copyFileExplorerPath(
worktreeSelector: string,
sourceRelativePath: string,
destinationRelativePath: string
): Promise<{ ok: true }> {
const sourceTarget = await this.resolveFileExplorerPath(worktreeSelector, sourceRelativePath)
const destinationTarget = await this.resolveFileExplorerPath(
worktreeSelector,
destinationRelativePath
)
const provider = sourceTarget.connectionId
? getSshFilesystemProvider(sourceTarget.connectionId)
: null
if (sourceTarget.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
await provider.copy(sourceTarget.path, destinationTarget.path)
return { ok: true }
}
const store = this.host.requireStore()
const sourcePath = await resolveAuthorizedPath(sourceTarget.path, store, {
preserveSymlink: true
})
const destinationPath = await resolveAuthorizedPath(destinationTarget.path, store, {
preserveSymlink: true
})
await mkdir(dirname(destinationPath), { recursive: true })
// Why: duplicate/copy operations are deconflicted by the caller. COPYFILE_EXCL
// preserves the same no-clobber invariant as the local shell copy IPC.
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL)
return { ok: true }
}
async deleteFileExplorerPath(
worktreeSelector: string,
relativePath: string,
recursive?: boolean
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
await provider.deletePath(target.path, recursive)
return { ok: true }
}
const targetPath = await resolveAuthorizedPath(target.path, this.host.requireStore(), {
preserveSymlink: true
})
// Why: a non-local runtime has no client OS Trash/Recycling Bin; server-side
// file mutations are permanent and the renderer confirms before calling this.
await rm(targetPath, { recursive: recursive === true, force: true })
return { ok: true }
}
async searchRuntimeFiles(
worktreeSelector: string,
options: Omit<SearchOptions, 'rootPath'>
): Promise<SearchResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
const rootPath = target.worktree.path
const searchOptions = { ...options, rootPath }
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
return provider.search(searchOptions)
}
return this.searchLocalRuntimeFiles(rootPath, searchOptions)
}
async listRuntimeFiles(
worktreeSelector: string,
options: { excludePaths?: string[] } = {}
): Promise<string[]> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
return []
}
return provider.listFiles(target.worktree.path, { excludePaths: options.excludePaths })
}
return listQuickOpenFiles(target.worktree.path, this.host.requireStore(), options.excludePaths)
}
async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise<MarkdownDocument[]> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
const relativePaths = await provider.listFiles(target.worktree.path)
return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths)
}
return listMarkdownDocuments(target.worktree.path)
}
async statRuntimeFile(
worktreeSelector: string,
relativePath: string
): Promise<{ size: number; isDirectory: boolean; mtime: number }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
const fileStat = await provider.stat(target.path)
return {
size: fileStat.size,
isDirectory: fileStat.type === 'directory',
mtime: fileStat.mtime
}
}
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
const stats = await stat(filePath)
return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs }
}
private async searchLocalRuntimeFiles(
rootPath: string,
options: SearchOptions
): Promise<SearchResult> {
const authorizedRootPath = await resolveAuthorizedPath(rootPath, this.host.requireStore())
const maxResults = Math.max(
1,
Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS)
)
const rgAvailable = await checkRgAvailable(authorizedRootPath)
if (!rgAvailable) {
return searchWithGitGrep(authorizedRootPath, options, maxResults)
}
return new Promise((resolvePromise) => {
const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}`
const rgArgs = buildRgArgs(options.query, authorizedRootPath, options)
this.activeRuntimeTextSearches.get(searchKey)?.kill()
const acc = createAccumulator()
let stdoutBuffer = ''
let resolved = false
let child: ChildProcess | null = null
const wslInfo = parseWslPath(authorizedRootPath)
const transformAbsPath = wslInfo
? (p: string): string => toWindowsWslPath(p, wslInfo.distro)
: undefined
const resolveOnce = (): void => {
if (resolved) {
return
}
resolved = true
if (this.activeRuntimeTextSearches.get(searchKey) === child) {
this.activeRuntimeTextSearches.delete(searchKey)
}
clearTimeout(killTimeout)
resolvePromise(finalize(acc))
}
const processLine = (line: string): void => {
const verdict = ingestRgJsonLine(
line,
authorizedRootPath,
acc,
maxResults,
transformAbsPath
)
if (verdict === 'stop') {
child?.kill()
}
}
const nextChild = wslAwareSpawn('rg', rgArgs, {
cwd: authorizedRootPath,
stdio: ['ignore', 'pipe', 'pipe']
})
child = nextChild
this.activeRuntimeTextSearches.set(searchKey, nextChild)
nextChild.stdout!.setEncoding('utf-8')
nextChild.stdout!.on('data', (chunk: string) => {
stdoutBuffer += chunk
const lines = stdoutBuffer.split('\n')
stdoutBuffer = lines.pop() ?? ''
for (const line of lines) {
processLine(line)
}
})
nextChild.stderr!.on('data', () => {
// Drain stderr so rg cannot block on a full pipe.
})
nextChild.once('error', () => resolveOnce())
nextChild.once('close', () => {
if (stdoutBuffer) {
processLine(stdoutBuffer)
}
resolveOnce()
})
const killTimeout = setTimeout(() => {
acc.truncated = true
child?.kill()
}, SEARCH_TIMEOUT_MS)
})
}
private async resolveFileExplorerPath(
worktreeSelector: string,
relativePath: string
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> {
const store = this.host.requireStore()
const worktree = await this.host.resolveWorktreeSelector(worktreeSelector)
const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath)
const repo = store.getRepo(worktree.repoId)
return {
worktree,
path: joinWorktreeRelativePath(worktree.path, normalizedRelativePath),
connectionId: repo?.connectionId ?? undefined
}
}
private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise<string[]> {
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
return []
}
return provider.listFiles(rootPath)
}
private async readRemoteMobileFile(filePath: string, connectionId: string): Promise<string> {
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
throw new Error('remote_filesystem_unavailable')
}
const fileStat = await provider.stat(filePath)
// Why: the SSH filesystem API does not expose ranged reads here, so reject
// oversized remote previews instead of streaming a large file just to trim it.
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
throw new Error('file_too_large')
}
const result = await provider.readFile(filePath)
if (result.isBinary) {
throw new Error('binary_file')
}
return result.content
}
}
function watchWindowsRuntimeFileExplorer(
rootPath: string,
callback: (events: FsChangeEvent[]) => void
): () => void {
let disposed = false
let timer: ReturnType<typeof setTimeout> | null = null
const emitOverflow = (): void => {
timer = null
if (disposed) {
return
}
callback([{ kind: 'overflow', absolutePath: rootPath }])
}
const scheduleOverflow = (): void => {
if (disposed) {
return
}
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(emitOverflow, WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS)
}
// Why: Parcel probes Watchman before the Windows backend and its native
// watcher can abort the headless server process. For remote Windows runtimes,
// a conservative overflow refresh is safer than a process-wide native crash.
const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow)
watcher.on('error', (err) => {
console.error('[runtime-files.watch] Windows watcher error', { rootPath, err })
scheduleOverflow()
})
return () => {
disposed = true
if (timer) {
clearTimeout(timer)
timer = null
}
try {
watcher.close()
} catch (err) {
console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err })
}
}
}
export function isSafeMobileRelativePath(relativePath: string): boolean {
if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) {
return false
}
const parts = relativePath.replace(/\\/g, '/').split('/')
return parts.every((part) => part !== '' && part !== '.' && part !== '..')
}
function isMobileMarkdownPath(relativePath: string): boolean {
return /\.(md|mdx|markdown)$/i.test(relativePath)
}
function isMobileBinaryPath(relativePath: string): boolean {
const basename = basenameFromRelativePath(relativePath)
const dotIndex = basename.lastIndexOf('.')
if (dotIndex <= 0) {
return false
}
return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase())
}
function basenameFromRelativePath(relativePath: string): string {
const normalized = relativePath.replace(/\\/g, '/')
return normalized.slice(normalized.lastIndexOf('/') + 1)
}
async function isRuntimeDirectoryEntry(entryPath: string): Promise<boolean> {
try {
return (await stat(entryPath)).isDirectory()
} catch {
return false
}
}
function isBinaryBuffer(buffer: Buffer): boolean {
const len = Math.min(buffer.length, 8192)
for (let i = 0; i < len; i += 1) {
if (buffer[i] === 0) {
return true
}
}
return false
}
async function assertRuntimePathDoesNotExist(targetPath: string): Promise<void> {
try {
await lstat(targetPath)
throw new Error(
`A file or folder named '${basename(targetPath)}' already exists in this location`
)
} catch (error) {
if (!isENOENT(error)) {
throw error
}
}
}
function rethrowRuntimeFileCreateError(error: unknown, targetPath: string): never {
const name = basename(targetPath)
if (error instanceof Error && 'code' in error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'EEXIST') {
throw new Error(`A file or folder named '${name}' already exists in this location`)
}
if (code === 'EACCES' || code === 'EPERM') {
throw new Error(`Permission denied: unable to create '${name}'`)
}
}
throw error
}
async function readLocalMobileFile(filePath: string, store: Store): Promise<string> {
const authorizedPath = await resolveAuthorizedPath(filePath, store)
const fileStat = await stat(authorizedPath)
// Why: mobile file previews are read-only convenience views; cap the read so
// opening a generated log or bundle cannot block the WebSocket like oversized scrollback.
const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1)
const handle = await open(authorizedPath, 'r')
try {
const buffer = Buffer.alloc(readLimit)
const { bytesRead } = await handle.read(buffer, 0, readLimit, 0)
return buffer.subarray(0, bytesRead).toString('utf8')
} finally {
await handle.close()
}
}
function truncateMobileFilePreview(content: string): {
content: string
truncated: boolean
byteLength: number
} {
const buffer = Buffer.from(content, 'utf8')
if (buffer.byteLength <= MOBILE_FILE_READ_MAX_BYTES) {
return { content, truncated: false, byteLength: buffer.byteLength }
}
return {
content: buffer.subarray(0, MOBILE_FILE_READ_MAX_BYTES).toString('utf8'),
truncated: true,
byteLength: buffer.byteLength
}
}
+308
View File
@@ -0,0 +1,308 @@
import type {
GitBranchCompareResult,
GitConflictOperation,
GitDiffResult,
GitPushTarget,
GitStatusResult,
GitUpstreamStatus,
GitWorktreeInfo,
Worktree
} from '../../shared/types'
import { getRemoteFileUrl } from '../git/repo'
import {
bulkStageFiles,
bulkUnstageFiles,
commitChanges,
detectConflictOperation,
discardChanges,
getBranchCompare,
getBranchDiff,
getDiff,
getStatus as getGitStatus,
stageFile,
unstageFile
} from '../git/status'
import { getUpstreamStatus } from '../git/upstream'
import { gitFetch, gitPull, gitPush } from '../git/remote'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
export type RuntimeGitCommandHost = {
resolveRuntimeGitTarget(
selector: string
): Promise<{ worktree: ResolvedRuntimeGitWorktree; connectionId?: string }>
}
export class RuntimeGitCommands {
constructor(private readonly host: RuntimeGitCommandHost) {}
async getRuntimeGitStatus(worktreeSelector: string): Promise<GitStatusResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
return provider.getStatus(target.worktree.path)
}
return getGitStatus(target.worktree.path)
}
async getRuntimeGitConflictOperation(worktreeSelector: string): Promise<GitConflictOperation> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
return provider.detectConflictOperation(target.worktree.path)
}
return detectConflictOperation(target.worktree.path)
}
async getRuntimeGitDiff(
worktreeSelector: string,
filePath: string,
staged: boolean,
compareAgainstHead?: boolean
): Promise<GitDiffResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
return provider.getDiff(target.worktree.path, relativePath, staged, compareAgainstHead)
}
return getDiff(target.worktree.path, relativePath, staged, compareAgainstHead)
}
async getRuntimeGitBranchCompare(
worktreeSelector: string,
baseRef: string
): Promise<GitBranchCompareResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
return provider.getBranchCompare(target.worktree.path, baseRef)
}
return getBranchCompare(target.worktree.path, baseRef)
}
async getRuntimeGitUpstreamStatus(worktreeSelector: string): Promise<GitUpstreamStatus> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
return provider.getUpstreamStatus(target.worktree.path)
}
return getUpstreamStatus(target.worktree.path)
}
async fetchRuntimeGit(worktreeSelector: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
await provider.fetchRemote(target.worktree.path)
return { ok: true }
}
await gitFetch(target.worktree.path)
return { ok: true }
}
async pullRuntimeGit(worktreeSelector: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
await provider.pullBranch(target.worktree.path)
return { ok: true }
}
await gitPull(target.worktree.path)
return { ok: true }
}
async pushRuntimeGit(
worktreeSelector: string,
publish?: boolean,
pushTarget?: GitPushTarget
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
await provider.pushBranch(target.worktree.path, publish === true, pushTarget)
return { ok: true }
}
await gitPush(target.worktree.path, publish === true, pushTarget)
return { ok: true }
}
async getRuntimeGitBranchDiff(
worktreeSelector: string,
compare: { mergeBase: string; headOid: string },
filePath: string,
oldPath?: string
): Promise<GitDiffResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeRelativePath(filePath)
const oldRelativePath = oldPath ? normalizeRuntimeRelativePath(oldPath) : undefined
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
const results = await provider.getBranchDiff(target.worktree.path, compare.mergeBase, {
includePatch: true,
filePath: relativePath,
oldPath: oldRelativePath
})
return (
results[0] ?? {
kind: 'text',
originalContent: '',
modifiedContent: '',
originalIsBinary: false,
modifiedIsBinary: false
}
)
}
return getBranchDiff(target.worktree.path, {
mergeBase: compare.mergeBase,
headOid: compare.headOid,
filePath: relativePath,
oldPath: oldRelativePath
})
}
async commitRuntimeGit(
worktreeSelector: string,
message: string
): Promise<{ success: boolean; error?: string }> {
if (message.trim().length === 0) {
throw new Error('Commit message is required')
}
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
return provider.commit(target.worktree.path, message)
}
return commitChanges(target.worktree.path, message)
}
async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
await provider.stageFile(target.worktree.path, relativePath)
return { ok: true }
}
await stageFile(target.worktree.path, relativePath)
return { ok: true }
}
async unstageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
await provider.unstageFile(target.worktree.path, relativePath)
return { ok: true }
}
await unstageFile(target.worktree.path, relativePath)
return { ok: true }
}
async bulkStageRuntimeGitPaths(
worktreeSelector: string,
filePaths: string[]
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path))
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
await provider.bulkStageFiles(target.worktree.path, relativePaths)
return { ok: true }
}
await bulkStageFiles(target.worktree.path, relativePaths)
return { ok: true }
}
async bulkUnstageRuntimeGitPaths(
worktreeSelector: string,
filePaths: string[]
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path))
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
await provider.bulkUnstageFiles(target.worktree.path, relativePaths)
return { ok: true }
}
await bulkUnstageFiles(target.worktree.path, relativePaths)
return { ok: true }
}
async discardRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
await provider.discardChanges(target.worktree.path, relativePath)
return { ok: true }
}
await discardChanges(target.worktree.path, relativePath)
return { ok: true }
}
async getRuntimeGitRemoteFileUrl(
worktreeSelector: string,
relativePath: string,
line: number
): Promise<string | null> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
return provider.getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line)
}
return getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line)
}
}
+552 -2
View File
@@ -5,11 +5,19 @@ import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree'
import {
createSetupRunnerScript,
getEffectiveHooks,
hasHooksFile,
parseOrcaYaml,
runHook,
shouldRunSetupForCreate
} from '../hooks'
import { getDefaultBaseRef } from '../git/repo'
import { OrchestrationDb } from './orchestration/db'
import { OrcaRuntimeService } from './orca-runtime'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
vi.mock('electron', () => ({
app: {
@@ -62,7 +70,8 @@ vi.mock('../hooks', () => ({
.fn()
.mockImplementation((_repo: never, decision: string) => decision === 'run'),
getEffectiveSetupRunPolicy: vi.fn().mockReturnValue('auto'),
hasHooksFile: vi.fn().mockReturnValue(false)
hasHooksFile: vi.fn().mockReturnValue(false),
parseOrcaYaml: vi.fn().mockReturnValue(null)
}))
vi.mock('../ipc/worktree-logic', async (importOriginal) => {
@@ -99,10 +108,14 @@ afterEach(() => {
vi.mocked(removeWorktree).mockReset()
vi.mocked(createSetupRunnerScript).mockReset()
vi.mocked(getEffectiveHooks).mockReset()
vi.mocked(hasHooksFile).mockReset()
vi.mocked(parseOrcaYaml).mockReset()
vi.mocked(runHook).mockReset()
vi.mocked(shouldRunSetupForCreate).mockReset()
vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run')
vi.mocked(getEffectiveHooks).mockReturnValue(null)
vi.mocked(hasHooksFile).mockReturnValue(false)
vi.mocked(parseOrcaYaml).mockReturnValue(null)
computeWorktreePathMock.mockReset()
ensurePathWithinWorkspaceMock.mockReset()
invalidateAuthorizedRootsCacheMock.mockReset()
@@ -143,6 +156,20 @@ function createRuntime(): OrcaRuntimeService {
return new OrcaRuntimeService(store)
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
const store = {
getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id),
getRepos: () => [
@@ -224,10 +251,15 @@ describe('OrcaRuntimeService', () => {
expect(runtime.getRuntimeId()).toBeTruthy()
})
it('reports protocol version and minimum compatible mobile version on status', () => {
it('reports runtime protocol, capabilities, and mobile aliases on status', () => {
const runtime = createRuntime()
const status = runtime.getStatus()
expect(typeof status.runtimeProtocolVersion).toBe('number')
expect(typeof status.minCompatibleRuntimeClientVersion).toBe('number')
expect(status.runtimeProtocolVersion).toBe(status.protocolVersion)
expect(status.minCompatibleRuntimeClientVersion).toBe(status.minCompatibleMobileVersion)
expect(status.capabilities).toContain('terminal.binary-stream.v1')
expect(typeof status.protocolVersion).toBe('number')
expect(typeof status.minCompatibleMobileVersion).toBe('number')
expect(status.protocolVersion).toBeGreaterThanOrEqual(1)
@@ -354,12 +386,481 @@ describe('OrcaRuntimeService', () => {
})
})
it('routes SSH-backed forward-slash UNC file and git paths without collapsing the root', async () => {
vi.mocked(listWorktrees).mockClear()
vi.mocked(listWorktrees).mockRejectedValue(new Error('local git should not run for SSH repos'))
const remoteStore = {
...store,
getRepos: () => [
{
id: TEST_REPO_ID,
path: '//Server/Share/Repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
}
],
getRepo: () => ({
id: TEST_REPO_ID,
path: '//Server/Share/Repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
})
}
const fsProvider = { readDir: vi.fn().mockResolvedValue([]) }
const gitProvider = {
listWorktrees: vi.fn().mockResolvedValue([
{
path: '//Server/Share/Repo',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
}
]),
getStatus: vi.fn().mockResolvedValue({
branch: 'feature/foo',
files: [],
ahead: 0,
behind: 0,
hasConflicts: false
})
}
registerSshFilesystemProvider('ssh-1', fsProvider as never)
registerSshGitProvider('ssh-1', gitProvider as never)
const runtime = new OrcaRuntimeService(remoteStore as never)
try {
await runtime.readFileExplorerDir('path://server/share/repo', 'src')
await runtime.getRuntimeGitStatus('path://server/share/repo')
await expect(runtime.showRepo('path://server/share/repo')).resolves.toMatchObject({
path: '//Server/Share/Repo'
})
} finally {
unregisterSshFilesystemProvider('ssh-1')
unregisterSshGitProvider('ssh-1')
}
expect(listWorktrees).not.toHaveBeenCalled()
expect(gitProvider.listWorktrees).toHaveBeenCalledWith('//Server/Share/Repo')
expect(fsProvider.readDir).toHaveBeenCalledWith('\\\\Server\\Share\\Repo\\src')
expect(gitProvider.getStatus).toHaveBeenCalledWith('//Server/Share/Repo')
})
it('does not interpret active as a runtime-global worktree selector', async () => {
const runtime = new OrcaRuntimeService(store)
await expect(runtime.showManagedWorktree('active')).rejects.toThrow('selector_not_found')
})
it('does not reuse stale in-flight worktree scans after creating a worktree', async () => {
const runtime = new OrcaRuntimeService(store)
const staleScan = deferred<typeof MOCK_GIT_WORKTREES>()
const createdWorktree = {
path: '/tmp/workspaces/cache-race',
head: 'def',
branch: 'cache-race',
isBare: false,
isMainWorktree: false
}
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
vi.mocked(listWorktrees)
.mockImplementationOnce(() => staleScan.promise)
.mockResolvedValueOnce([createdWorktree])
.mockResolvedValueOnce([...MOCK_GIT_WORKTREES, createdWorktree])
const staleLookup = runtime.showManagedWorktree(TEST_WORKTREE_ID)
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'cache-race'
})
const freshLookup = runtime.showManagedWorktree(result.worktree.id)
staleScan.resolve(MOCK_GIT_WORKTREES)
await expect(staleLookup).resolves.toMatchObject({ id: TEST_WORKTREE_ID })
await expect(freshLookup).resolves.toMatchObject({
id: result.worktree.id,
path: createdWorktree.path
})
})
it('does not run local git when runtime worktree creation targets an SSH repo', async () => {
vi.mocked(listWorktrees).mockClear()
vi.mocked(addWorktree).mockClear()
const remoteStore = {
...store,
getRepos: () => [
{
id: TEST_REPO_ID,
path: '/remote/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
}
]
}
const runtime = new OrcaRuntimeService(remoteStore as never)
await expect(
runtime.createManagedWorktree({ repoSelector: TEST_REPO_ID, name: 'feature' })
).rejects.toThrow('SSH-backed worktree creation is not supported through runtime RPC yet')
expect(addWorktree).not.toHaveBeenCalled()
expect(listWorktrees).not.toHaveBeenCalled()
})
it('removes SSH-backed runtime worktrees through the SSH git provider', async () => {
vi.mocked(listWorktrees).mockClear()
const remoteStore = {
...store,
getRepos: () => [
{
id: TEST_REPO_ID,
path: '/remote/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
}
],
getRepo: () => ({
id: TEST_REPO_ID,
path: '/remote/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
})
}
const gitProvider = {
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/repo',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: true
}
]),
removeWorktree: vi.fn().mockResolvedValue(undefined)
}
registerSshGitProvider('ssh-1', gitProvider as never)
const runtime = new OrcaRuntimeService(remoteStore as never)
try {
await runtime.removeManagedWorktree('path:/remote/repo', true)
} finally {
unregisterSshGitProvider('ssh-1')
}
expect(gitProvider.removeWorktree).toHaveBeenCalledWith('/remote/repo', true)
expect(removeWorktree).not.toHaveBeenCalled()
expect(listWorktrees).not.toHaveBeenCalled()
})
it('reads SSH repo hooks through the SSH filesystem provider', async () => {
const remoteStore = {
...store,
getRepos: () => [
{
id: TEST_REPO_ID,
path: 'C:/remote/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
}
]
}
const fsProvider = {
readFile: vi.fn().mockResolvedValue({
content: 'scripts:\n setup: pnpm install\n',
isBinary: false
})
}
vi.mocked(parseOrcaYaml).mockReturnValue({ scripts: { setup: 'pnpm install' } })
registerSshFilesystemProvider('ssh-1', fsProvider as never)
const runtime = new OrcaRuntimeService(remoteStore as never)
try {
await expect(runtime.getRepoHooks('id:repo-1')).resolves.toMatchObject({
hasHooksFile: true,
hooks: { scripts: { setup: 'pnpm install' } },
source: 'orca.yaml'
})
} finally {
unregisterSshFilesystemProvider('ssh-1')
}
expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca.yaml')
expect(hasHooksFile).not.toHaveBeenCalled()
expect(getEffectiveHooks).not.toHaveBeenCalled()
})
it('uses remote path joins for SSH hook checks and issue-command files', async () => {
const remoteStore = {
...store,
getRepos: () => [
{
id: TEST_REPO_ID,
path: 'C:/remote/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
}
]
}
const fsProvider = {
readFile: vi.fn(async (filePath: string) => ({
content: filePath.includes('.orca.yaml')
? 'scripts:\n setup: pnpm install\n'
: filePath.endsWith('.gitignore')
? 'node_modules\n'
: 'Fix it',
isBinary: false
})),
writeFile: vi.fn().mockResolvedValue(undefined),
createDir: vi.fn().mockResolvedValue(undefined),
deletePath: vi.fn().mockResolvedValue(undefined)
}
registerSshFilesystemProvider('ssh-1', fsProvider as never)
const runtime = new OrcaRuntimeService(remoteStore as never)
try {
await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({
hasHooks: true,
mayNeedUpdate: false
})
await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({
localContent: 'Fix it',
effectiveContent: 'Fix it',
localFilePath: 'C:\\remote\\repo\\.orca\\issue-command'
})
await expect(runtime.writeRepoIssueCommand('id:repo-1', 'Ship it')).resolves.toEqual({
ok: true
})
} finally {
unregisterSshFilesystemProvider('ssh-1')
}
expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca.yaml')
expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca\\issue-command')
expect(fsProvider.createDir).toHaveBeenCalledWith('C:\\remote\\repo\\.orca')
expect(fsProvider.writeFile).toHaveBeenCalledWith(
'C:\\remote\\repo\\.orca\\issue-command',
'Ship it\n'
)
expect(fsProvider.writeFile).toHaveBeenCalledWith(
'C:\\remote\\repo\\.gitignore',
'node_modules\n.orca\n'
)
})
it('resolves SSH issue commands from shared orca.yaml and deletes empty overrides', async () => {
const remoteStore = {
...store,
getRepos: () => [
{
id: TEST_REPO_ID,
path: '/remote/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
}
]
}
vi.mocked(parseOrcaYaml).mockReturnValue({
scripts: {},
issueCommand: 'claude -p "Fix #{{issue}}"'
})
const fsProvider = {
readFile: vi.fn(async (filePath: string) => {
if (filePath.endsWith('.orca/issue-command')) {
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
}
if (filePath.endsWith('orca.yaml')) {
return { content: 'issueCommand: claude -p "Fix #{{issue}}"', isBinary: false }
}
return { content: '', isBinary: false }
}),
writeFile: vi.fn().mockResolvedValue(undefined),
createDir: vi.fn().mockResolvedValue(undefined),
deletePath: vi.fn().mockResolvedValue(undefined)
}
registerSshFilesystemProvider('ssh-1', fsProvider as never)
const runtime = new OrcaRuntimeService(remoteStore as never)
try {
await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({
localContent: null,
sharedContent: 'claude -p "Fix #{{issue}}"',
effectiveContent: 'claude -p "Fix #{{issue}}"',
localFilePath: '/remote/repo/.orca/issue-command',
source: 'shared'
})
await expect(runtime.writeRepoIssueCommand('id:repo-1', ' ')).resolves.toEqual({
ok: true
})
} finally {
unregisterSshFilesystemProvider('ssh-1')
}
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml')
expect(fsProvider.deletePath).toHaveBeenCalledWith('/remote/repo/.orca/issue-command', false)
expect(fsProvider.writeFile).not.toHaveBeenCalledWith(
'/remote/repo/.orca/issue-command',
expect.anything()
)
})
it('rejects host integration helpers for SSH repos instead of using remote paths locally', async () => {
const remoteStore = {
...store,
getRepos: () => [
{
id: TEST_REPO_ID,
path: '/remote/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
}
]
}
const runtime = new OrcaRuntimeService(remoteStore as never)
await expect(runtime.getRepoSlug('id:repo-1')).rejects.toThrow(
'repo_slug_unsupported_for_ssh_repo'
)
await expect(runtime.listRepoWorkItems('id:repo-1')).rejects.toThrow(
'repo_work_items_unsupported_for_ssh_repo'
)
})
it('treats SSH worktree drift as unknown without local git probes', async () => {
vi.mocked(listWorktrees).mockClear()
vi.mocked(getDefaultBaseRef).mockClear()
const remoteStore = {
...store,
getRepos: () => [
{
id: TEST_REPO_ID,
path: '/remote/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
}
],
getWorktreeMeta: () => null
}
const gitProvider = {
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/repo',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: true
}
])
}
registerSshGitProvider('ssh-1', gitProvider as never)
const runtime = new OrcaRuntimeService(remoteStore as never)
try {
await expect(runtime.probeWorktreeDrift('path:/remote/repo')).resolves.toBeNull()
} finally {
unregisterSshGitProvider('ssh-1')
}
expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo')
expect(getDefaultBaseRef).not.toHaveBeenCalled()
expect(listWorktrees).not.toHaveBeenCalled()
})
it('deduplicates runtime repo paths with Windows/UNC comparison semantics', async () => {
const added: Record<string, unknown>[] = []
const uncStore = {
...store,
getRepos: () => [
{
id: 'repo-unc',
path: '//Server/Share/Repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
kind: 'folder'
},
...added
],
addRepo: (repo: Record<string, unknown>) => {
added.push(repo)
},
getRepo: (id: string) => [...uncStore.getRepos()].find((repo) => repo.id === id) as never
}
const runtime = new OrcaRuntimeService(uncStore as never)
const repo = await runtime.addRepo('//server/share/repo', 'folder')
expect(repo).toMatchObject({ id: 'repo-unc', path: '//Server/Share/Repo' })
expect(added).toHaveLength(0)
})
it('associates controller PTYs with mixed-case Windows and UNC cwd paths', async () => {
vi.mocked(listWorktrees).mockResolvedValue([
{
path: 'C:\\Repo',
head: 'abc',
branch: 'feature/windows',
isBare: false,
isMainWorktree: true
},
{
path: '//Server/Share/Repo',
head: 'def',
branch: 'feature/unc',
isBare: false,
isMainWorktree: false
}
])
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: 'pty-windows', cwd: 'c:\\repo\\src', title: 'Windows shell' },
{ id: 'pty-unc', cwd: '//server/share/repo/src', title: 'UNC shell' }
]
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
const terminals = await runtime.listTerminals()
expect(terminals.terminals).toEqual(
expect.arrayContaining([
expect.objectContaining({
worktreeId: `${TEST_REPO_ID}::C:\\Repo`,
worktreePath: 'C:\\Repo'
}),
expect.objectContaining({
worktreeId: `${TEST_REPO_ID}:://Server/Share/Repo`,
worktreePath: '//Server/Share/Repo'
})
])
)
})
it('reads bounded terminal output and writes through the PTY controller', async () => {
const writes: string[] = []
const runtime = new OrcaRuntimeService(store)
@@ -1102,6 +1603,55 @@ describe('OrcaRuntimeService', () => {
}
})
it('creates mobile session terminals in a headless runtime server', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`)
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID,
preAllocatedHandle: expect.stringMatching(/^term_/)
})
)
expect(result.tab).toMatchObject({
type: 'terminal',
status: 'ready',
terminal: expect.stringMatching(/^term_/),
isActive: true
})
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabs).toEqual([
expect.objectContaining({
id: result.tab.id,
status: 'ready',
terminal: result.tab.terminal
})
])
})
it('reports browser tab creation as unsupported for headless runtime servers', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
await expect(
runtime.browserTabCreate({ worktree: `id:${TEST_WORKTREE_ID}`, url: 'https://example.com' })
).rejects.toMatchObject({
code: 'browser_error',
message: expect.stringContaining('headless orca serve')
})
})
it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => {
const runtime = new OrcaRuntimeService(store)
const db = new OrchestrationDb(':memory:')
File diff suppressed because it is too large Load Diff
@@ -20,9 +20,9 @@ import { existsSync, mkdtempSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
import { OrcaRuntimeService } from '../main/runtime/orca-runtime'
import { OrchestrationDb } from '../main/runtime/orchestration/db'
import { OrcaRuntimeRpcServer } from '../main/runtime/runtime-rpc'
import { OrcaRuntimeService } from './orca-runtime'
import { OrchestrationDb } from './orchestration/db'
import { OrcaRuntimeRpcServer } from './runtime-rpc'
// Why: Vitest runs tests with `process.cwd()` pinned to the repo root, so
// join against it to locate the compiled CLI regardless of where this test
@@ -108,12 +108,12 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => {
expect(heartbeatLines[0]).toHaveProperty('elapsedMs')
expect(heartbeatLines[0]).toHaveProperty('deadlineMs', waitTimeoutMs)
// Why: first heartbeat must arrive within one interval + scheduler
// slack (300ms is generous); if the stream were fully buffered we'd
// see everything only after exit.
// Why: under full-suite load the child process startup may take longer
// than one heartbeat interval. The invariant that matters is that at
// least one heartbeat is observed before the terminal stdout payload.
const firstHeartbeatChunk = stderrChunks.find((c) => c.data.includes('_heartbeat'))
expect(firstHeartbeatChunk).toBeDefined()
expect(firstHeartbeatChunk!.at).toBeLessThan(heartbeatMs + 300)
expect(firstHeartbeatChunk!.at).toBeLessThan(stdoutChunks[0]?.at ?? Number.POSITIVE_INFINITY)
// Why: line-flushing proof — the *first* heartbeat chunk must arrive
// strictly before the exit chunk; i.e. we got at least two separate
@@ -0,0 +1,67 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
import { getDefaultRepoHookSettings } from '../../shared/constants'
import type { Repo } from '../../shared/types'
import { parsePairingCode } from '../../shared/pairing'
import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection'
import type { OrcaRuntimeService } from './orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime-rpc'
describe('remote runtime request connection integration', () => {
it('fetches repos through the real E2EE WebSocket runtime', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-request-'))
const repoPath = join(userDataPath, 'repo')
const repos: Repo[] = [
{
id: 'repo-1',
path: repoPath,
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
hookSettings: getDefaultRepoHookSettings(),
worktreeBaseRef: 'main',
kind: 'git'
}
]
const runtime = {
getRuntimeId: () => 'runtime-test',
getStartedAt: () => 1,
cleanupSubscriptionsForConnection: () => {},
cancelMobileDictationForConnection: () => {},
onClientDisconnected: () => {},
listRepos: () => repos
} as unknown as OrcaRuntimeService
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' })
if (!offer.available) {
throw new Error('pairing unavailable')
}
const pairing = parsePairingCode(offer.pairingUrl)
if (!pairing) {
throw new Error('invalid pairing')
}
const connection = new RemoteRuntimeRequestConnection(pairing)
try {
await expect(connection.request('repo.list', undefined, 1000)).resolves.toMatchObject({
ok: true,
result: { repos }
})
} finally {
connection.close()
}
} finally {
await server.stop()
rmSync(userDataPath, { recursive: true, force: true })
}
})
})
+8
View File
@@ -3,6 +3,7 @@
// CLI-facing contract greppable and lets the dispatcher verify every payload
// against the same shape the handler consumed during development.
import { ZodError, type ZodType } from 'zod'
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
import type { OrcaRuntimeService } from '../orca-runtime'
export type RpcEnvelopeMeta = {
@@ -59,6 +60,13 @@ export type RpcContext = {
// responses after the binary terminal cutover. Undefined on Unix/socket
// transports and non-E2EE WebSocket paths.
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
// Why: binary terminal input/resize frames arrive outside JSON-RPC after a
// stream is established. The WebSocket transport owns the connection-scoped
// stream table; handlers register only the stream IDs they created.
registerBinaryStreamHandler?: (
streamId: number,
handler: (frame: TerminalStreamFrame) => void
) => () => void
}
export type RpcHandler<TParams> = (params: TParams, ctx: RpcContext) => Promise<unknown> | unknown
+12 -2
View File
@@ -13,6 +13,7 @@ import {
type RpcRequest,
type RpcResponse
} from './core'
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors'
import { ALL_RPC_METHODS } from './methods'
import type { OrcaRuntimeService } from '../orca-runtime'
@@ -79,8 +80,13 @@ export class RpcDispatcher {
reply: (response: string) => void,
options?: {
connectionId?: string
signal?: AbortSignal
clientId?: string
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
registerBinaryStreamHandler?: (
streamId: number,
handler: (frame: TerminalStreamFrame) => void
) => () => void
}
): Promise<void> {
const meta = this.meta()
@@ -104,9 +110,11 @@ export class RpcDispatcher {
try {
const result = await method.handler(parsedParams.value, {
runtime: this.runtime,
signal: options?.signal,
connectionId: options?.connectionId,
clientId: options?.clientId,
sendBinary: options?.sendBinary
sendBinary: options?.sendBinary,
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
})
reply(JSON.stringify(successResponse(request.id, meta, result)))
} catch (error) {
@@ -126,9 +134,11 @@ export class RpcDispatcher {
parsedParams.value,
{
runtime: this.runtime,
signal: options?.signal,
connectionId: options?.connectionId,
clientId: options?.clientId,
sendBinary: options?.sendBinary
sendBinary: options?.sendBinary,
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
},
emit
)
+15 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import type { WebSocket } from 'ws'
import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel'
import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto'
import { generateKeyPair, deriveSharedKey, encrypt, decrypt, encryptBytes } from './e2ee-crypto'
function publicKeyToBase64(key: Uint8Array): string {
return Buffer.from(key).toString('base64')
@@ -182,6 +182,20 @@ describe('E2EEChannel', () => {
expect(replyPlain).toBe('{"id":"rpc-1","ok":true}')
})
it('decrypts and forwards binary messages after authentication', () => {
const ctx = setup()
const sharedKey = doHandshake(ctx)
const received: Uint8Array<ArrayBufferLike>[] = []
ctx.channel.onBinaryMessage((bytes) => {
received.push(bytes)
})
ctx.channel.handleRawMessage(encryptBytes(new Uint8Array([1, 2, 3]), sharedKey))
expect([...received[0]!]).toEqual([1, 2, 3])
})
it('silently drops messages with wrong key', () => {
const ctx = setup()
doHandshake(ctx)
+13
View File
@@ -46,6 +46,7 @@ export class E2EEChannel {
encryptedBinaryReply: (response: Uint8Array<ArrayBufferLike>) => void
) => void)
| null = null
private binaryMessageHandler: ((plaintext: Uint8Array<ArrayBufferLike>) => void) | null = null
deviceToken: string | null = null
@@ -71,6 +72,10 @@ export class E2EEChannel {
this.messageHandler = handler
}
onBinaryMessage(handler: (plaintext: Uint8Array<ArrayBufferLike>) => void): void {
this.binaryMessageHandler = handler
}
handleRawMessage(raw: string | Uint8Array<ArrayBufferLike>): void {
if (this.state === 'awaiting_hello') {
if (typeof raw !== 'string') {
@@ -89,7 +94,14 @@ export class E2EEChannel {
const plaintextBytes = decryptBytes(raw, this.sharedKey)
if (plaintextBytes === null) {
this.trackDecryptFailure()
return
}
this.consecutiveFailures = 0
if (this.state !== 'ready') {
this.onError(4001, 'Invalid binary message before authentication')
return
}
this.binaryMessageHandler?.(plaintextBytes)
return
}
@@ -211,5 +223,6 @@ export class E2EEChannel {
}
this.sharedKey = null
this.messageHandler = null
this.binaryMessageHandler = null
}
}
+8 -53
View File
@@ -1,53 +1,8 @@
// Why: shared E2EE primitives for the desktop side. Wraps tweetnacl to provide
// encrypt/decrypt with the NaCl box format: [24-byte nonce][ciphertext]. JSON
// RPC uses base64 text frames; terminal streams use the raw byte bundle.
import nacl from 'tweetnacl'
export function generateKeyPair(): nacl.BoxKeyPair {
return nacl.box.keyPair()
}
export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array {
return nacl.box.before(peerPublicKey, ourSecretKey)
}
export function encrypt(plaintext: string, sharedKey: Uint8Array): string {
const messageBytes = new TextEncoder().encode(plaintext)
return Buffer.from(encryptBytes(messageBytes, sharedKey)).toString('base64')
}
export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null {
const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64'))
const plaintext = decryptBytes(bundle, sharedKey)
return plaintext ? new TextDecoder().decode(plaintext) : null
}
export function encryptBytes(
plaintext: Uint8Array<ArrayBufferLike>,
sharedKey: Uint8Array
): Uint8Array {
const nonce = nacl.randomBytes(nacl.box.nonceLength)
const ciphertext = nacl.box.after(plaintext, nonce, sharedKey)
const bundle = new Uint8Array(nonce.length + ciphertext.length)
bundle.set(nonce)
bundle.set(ciphertext, nonce.length)
return bundle
}
export function decryptBytes(bundle: Uint8Array, sharedKey: Uint8Array): Uint8Array | null {
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 plaintext
}
export {
decrypt,
decryptBytes,
deriveSharedKey,
encrypt,
encryptBytes,
generateKeyPair
} from '../../../shared/e2ee-crypto'
@@ -18,6 +18,7 @@ import {
LimitParam,
ProfileCreate,
ProfileDelete,
ProfileImportFromBrowser,
Screenshot,
Scroll,
Select,
@@ -151,6 +152,21 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [
params: ProfileDelete,
handler: async (params, { runtime }) => runtime.browserProfileDelete(params)
}),
defineMethod({
name: 'browser.profileDetectBrowsers',
params: null,
handler: async (_params, { runtime }) => runtime.browserProfileDetectBrowsers()
}),
defineMethod({
name: 'browser.profileImportFromBrowser',
params: ProfileImportFromBrowser,
handler: async (params, { runtime }) => runtime.browserProfileImportFromBrowser(params)
}),
defineMethod({
name: 'browser.profileClearDefaultCookies',
params: null,
handler: async (_params, { runtime }) => runtime.browserProfileClearDefaultCookies()
}),
defineMethod({
name: 'browser.hover',
params: Element,
@@ -143,6 +143,12 @@ export const ProfileDelete = z.object({
profileId: requiredString('Missing required --profile')
})
export const ProfileImportFromBrowser = z.object({
profileId: requiredString('Missing required --profile'),
browserFamily: requiredString('Missing required --browser-family'),
browserProfile: OptionalString
})
export const Drag = BrowserTarget.extend({
from: requiredString('Missing required --from and --to element refs'),
to: requiredString('Missing required --from and --to element refs')
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { BROWSER_CORE_METHODS } from './browser-core'
import { BROWSER_EXTRA_METHODS } from './browser-extras'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('browser RPC methods', () => {
it('routes core browser automation commands to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
browserSnapshot: vi.fn().mockResolvedValue({ elements: [] }),
browserGoto: vi.fn().mockResolvedValue({ url: 'https://example.com' }),
browserProfileDetectBrowsers: vi.fn().mockResolvedValue({ browsers: [] }),
browserProfileImportFromBrowser: vi.fn().mockResolvedValue({ ok: false, reason: 'empty' }),
browserTabCreate: vi.fn().mockResolvedValue({ browserPageId: 'page-1' }),
browserTabSwitch: vi.fn().mockResolvedValue({ browserPageId: 'page-1' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS })
await dispatcher.dispatch(makeRequest('browser.snapshot', { worktree: 'id:wt-1' }))
await dispatcher.dispatch(
makeRequest('browser.goto', {
worktree: 'id:wt-1',
page: 'page-1',
url: 'https://example.com'
})
)
await dispatcher.dispatch(
makeRequest('browser.tabCreate', {
worktree: 'id:wt-1',
url: 'https://example.com',
profileId: 'profile-1'
})
)
await dispatcher.dispatch(
makeRequest('browser.tabSwitch', {
worktree: 'id:wt-1',
index: 0,
focus: true
})
)
await dispatcher.dispatch(makeRequest('browser.profileDetectBrowsers'))
await dispatcher.dispatch(
makeRequest('browser.profileImportFromBrowser', {
profileId: 'profile-1',
browserFamily: 'chrome',
browserProfile: 'Default'
})
)
expect(runtime.browserSnapshot).toHaveBeenCalledWith({ worktree: 'id:wt-1' })
expect(runtime.browserGoto).toHaveBeenCalledWith({
worktree: 'id:wt-1',
page: 'page-1',
url: 'https://example.com'
})
expect(runtime.browserTabCreate).toHaveBeenCalledWith({
worktree: 'id:wt-1',
url: 'https://example.com',
profileId: 'profile-1'
})
expect(runtime.browserTabSwitch).toHaveBeenCalledWith({
worktree: 'id:wt-1',
index: 0,
focus: true
})
expect(runtime.browserProfileDetectBrowsers).toHaveBeenCalled()
expect(runtime.browserProfileImportFromBrowser).toHaveBeenCalledWith({
profileId: 'profile-1',
browserFamily: 'chrome',
browserProfile: 'Default'
})
})
it('routes browser session and environment controls to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
browserCookieGet: vi.fn().mockResolvedValue({ cookies: [] }),
browserSetViewport: vi.fn().mockResolvedValue({ ok: true }),
browserMouseWheel: vi.fn().mockResolvedValue({ ok: true }),
browserStorageLocalSet: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_EXTRA_METHODS })
await dispatcher.dispatch(
makeRequest('browser.cookie.get', {
worktree: 'id:wt-1',
page: 'page-1',
url: 'https://example.com'
})
)
await dispatcher.dispatch(
makeRequest('browser.viewport', {
worktree: 'id:wt-1',
page: 'page-1',
width: 1024,
height: 768
})
)
await dispatcher.dispatch(
makeRequest('browser.mouseWheel', {
worktree: 'id:wt-1',
page: 'page-1',
dy: 240
})
)
await dispatcher.dispatch(
makeRequest('browser.storage.local.set', {
worktree: 'id:wt-1',
page: 'page-1',
key: 'orca',
value: 'enabled'
})
)
expect(runtime.browserCookieGet).toHaveBeenCalledWith({
worktree: 'id:wt-1',
page: 'page-1',
url: 'https://example.com'
})
expect(runtime.browserSetViewport).toHaveBeenCalledWith({
worktree: 'id:wt-1',
page: 'page-1',
width: 1024,
height: 768
})
expect(runtime.browserMouseWheel).toHaveBeenCalledWith({
worktree: 'id:wt-1',
page: 'page-1',
dy: 240
})
expect(runtime.browserStorageLocalSet).toHaveBeenCalledWith({
worktree: 'id:wt-1',
page: 'page-1',
key: 'orca',
value: 'enabled'
})
})
})
@@ -0,0 +1,65 @@
import type { FsChangeEvent } from '../../../../shared/types'
const FILE_WATCH_FLUSH_MS = 150
const FILE_WATCH_MAX_WAIT_MS = 500
export function createFileWatchEventBatcher(
worktree: string,
emit: (result: unknown) => void
): {
push: (events: FsChangeEvent[]) => void
flush: () => void
dispose: () => void
} {
let events: FsChangeEvent[] = []
let timer: ReturnType<typeof setTimeout> | null = null
let firstEventAt = 0
const clearTimer = (): void => {
if (!timer) {
return
}
clearTimeout(timer)
timer = null
}
const flush = (): void => {
clearTimer()
const nextEvents = events.splice(0)
firstEventAt = 0
if (nextEvents.length === 0) {
return
}
emit({ type: 'changed', worktree, events: nextEvents })
}
return {
push(nextEvents: FsChangeEvent[]): void {
if (nextEvents.length === 0) {
return
}
events.push(...nextEvents)
const now = Date.now()
if (firstEventAt === 0) {
firstEventAt = now
}
if (now - firstEventAt >= FILE_WATCH_MAX_WAIT_MS) {
flush()
return
}
clearTimer()
// Why: remote file-watch events cross the runtime WebSocket before the
// renderer refreshes the tree. Match local watcher batching here.
timer = setTimeout(flush, FILE_WATCH_FLUSH_MS)
if (typeof timer.unref === 'function') {
timer.unref()
}
},
flush,
dispose(): void {
clearTimer()
events = []
firstEventAt = 0
}
}
}
+394
View File
@@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: file RPC routing coverage stays together so
the dispatcher contract for read, write, mutation, and watch methods is easy to audit. */
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
@@ -54,6 +56,117 @@ describe('file RPC methods', () => {
})
})
it('streams file watch changes until the subscription is cleaned up', async () => {
vi.useFakeTimers()
try {
type WatchCallback = (
events: { kind: 'update'; absolutePath: string; isDirectory?: boolean }[]
) => void
const watchFileExplorer = vi.fn(async (_worktree: string, _callback: WatchCallback) => {
return vi.fn()
})
const cleanups = new Map<string, () => void>()
const runtime = {
getRuntimeId: () => 'test-runtime',
watchFileExplorer,
registerSubscriptionCleanup: vi.fn().mockImplementation((id, cleanup) => {
cleanups.set(id, cleanup)
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const replies: unknown[] = []
const dispatch = dispatcher.dispatchStreaming(
makeRequest('files.watch', { worktree: 'id:wt-1' }),
(response) => replies.push(JSON.parse(response))
)
await vi.waitFor(() => {
expect(replies).toHaveLength(1)
})
expect(runtime.watchFileExplorer).toHaveBeenCalledWith('id:wt-1', expect.any(Function))
expect(replies[0]).toMatchObject({
ok: true,
streaming: true,
result: { type: 'ready', subscriptionId: expect.stringContaining('files-watch-') }
})
const emitWatchChange = watchFileExplorer.mock.calls[0]?.[1]
expect(emitWatchChange).toBeDefined()
emitWatchChange?.([{ kind: 'update', absolutePath: '/repo/readme.md', isDirectory: false }])
emitWatchChange?.([
{ kind: 'update', absolutePath: '/repo/package.json', isDirectory: false }
])
expect(replies).toHaveLength(1)
await vi.runOnlyPendingTimersAsync()
expect(replies[1]).toMatchObject({
ok: true,
streaming: true,
result: {
type: 'changed',
worktree: 'id:wt-1',
events: [
{ kind: 'update', absolutePath: '/repo/readme.md', isDirectory: false },
{ kind: 'update', absolutePath: '/repo/package.json', isDirectory: false }
]
}
})
const ready = replies[0] as { result: { subscriptionId: string } }
cleanups.get(ready.result.subscriptionId)?.()
await dispatch
expect(replies[2]).toMatchObject({
ok: true,
streaming: true,
result: { type: 'end' }
})
} finally {
vi.useRealTimers()
}
})
it('tears down a file watch that resolves after the connection already closed', async () => {
type WatchCallback = (
events: { kind: 'update'; absolutePath: string; isDirectory?: boolean }[]
) => void
const unwatch = vi.fn()
let resolveWatch: (value: () => void) => void = () => {}
const watchFileExplorer = vi.fn((_worktree: string, _callback: WatchCallback) => {
return new Promise<() => void>((resolve) => {
resolveWatch = resolve
})
})
const runtime = {
getRuntimeId: () => 'test-runtime',
watchFileExplorer,
registerSubscriptionCleanup: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const abortController = new AbortController()
const replies: unknown[] = []
const dispatch = dispatcher.dispatchStreaming(
makeRequest('files.watch', { worktree: 'id:wt-1' }),
(response) => replies.push(JSON.parse(response)),
{ connectionId: 'conn-1', signal: abortController.signal }
)
await vi.waitFor(() => {
expect(watchFileExplorer).toHaveBeenCalled()
})
abortController.abort()
await dispatch
resolveWatch(unwatch)
await vi.waitFor(() => {
expect(unwatch).toHaveBeenCalled()
})
expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalled()
expect(replies).toEqual([])
})
it('reads a relative file path for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@@ -77,4 +190,285 @@ describe('file RPC methods', () => {
result: { content: 'export {}\\n', truncated: false }
})
})
it('reads a preview file for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
readFileExplorerPreview: vi.fn().mockResolvedValue({
content: 'base64',
isBinary: true,
isImage: true,
mimeType: 'image/png'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.readPreview', { worktree: 'id:wt-1', relativePath: 'img/logo.png' })
)
expect(runtime.readFileExplorerPreview).toHaveBeenCalledWith('id:wt-1', 'img/logo.png')
expect(response).toMatchObject({
ok: true,
result: { content: 'base64', isBinary: true, mimeType: 'image/png' }
})
})
it('reads a file explorer directory for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
readFileExplorerDir: vi.fn().mockResolvedValue([{ name: 'src', isDirectory: true }])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.readDir', { worktree: 'id:wt-1', relativePath: '' })
)
expect(runtime.readFileExplorerDir).toHaveBeenCalledWith('id:wt-1', '')
expect(response).toMatchObject({
ok: true,
result: [{ name: 'src', isDirectory: true }]
})
})
it('writes file explorer content for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.write', {
worktree: 'id:wt-1',
relativePath: 'src/index.ts',
content: 'export {}'
})
)
expect(runtime.writeFileExplorerFile).toHaveBeenCalledWith(
'id:wt-1',
'src/index.ts',
'export {}'
)
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('writes base64 file explorer content for runtime uploads', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
writeFileExplorerFileBase64: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.writeBase64', {
worktree: 'id:wt-1',
relativePath: 'assets/logo.png',
contentBase64: 'cG5n'
})
)
expect(runtime.writeFileExplorerFileBase64).toHaveBeenCalledWith(
'id:wt-1',
'assets/logo.png',
'cG5n'
)
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('writes base64 file explorer content chunks for large runtime uploads', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
writeFileExplorerFileBase64Chunk: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.writeBase64Chunk', {
worktree: 'id:wt-1',
relativePath: 'assets/video.mov',
contentBase64: 'AAAA',
append: true
})
)
expect(runtime.writeFileExplorerFileBase64Chunk).toHaveBeenCalledWith(
'id:wt-1',
'assets/video.mov',
'AAAA',
true
)
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('commits staged runtime uploads without clobbering the final destination', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
commitFileExplorerUpload: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.commitUpload', {
worktree: 'id:wt-1',
tempRelativePath: 'assets/.logo.png.orca-upload-a',
finalRelativePath: 'assets/logo.png'
})
)
expect(runtime.commitFileExplorerUpload).toHaveBeenCalledWith(
'id:wt-1',
'assets/.logo.png.orca-upload-a',
'assets/logo.png'
)
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('renames file explorer paths for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
renameFileExplorerPath: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.rename', {
worktree: 'id:wt-1',
oldRelativePath: 'old.ts',
newRelativePath: 'new.ts'
})
)
expect(runtime.renameFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'old.ts', 'new.ts')
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('copies file explorer paths for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
copyFileExplorerPath: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.copy', {
worktree: 'id:wt-1',
sourceRelativePath: 'old.ts',
destinationRelativePath: 'old copy.ts'
})
)
expect(runtime.copyFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'old.ts', 'old copy.ts')
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('deletes file explorer paths for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
deleteFileExplorerPath: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.delete', {
worktree: 'id:wt-1',
relativePath: 'src',
recursive: true
})
)
expect(runtime.deleteFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'src', true)
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('searches files for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
searchRuntimeFiles: vi.fn().mockResolvedValue({
files: [],
totalMatches: 0,
truncated: false
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.search', {
worktree: 'id:wt-1',
query: 'needle',
caseSensitive: true,
maxResults: 50
})
)
expect(runtime.searchRuntimeFiles).toHaveBeenCalledWith('id:wt-1', {
query: 'needle',
caseSensitive: true,
wholeWord: undefined,
useRegex: undefined,
includePattern: undefined,
excludePattern: undefined,
maxResults: 50
})
expect(response).toMatchObject({ ok: true, result: { files: [], totalMatches: 0 } })
})
it('lists all quick-open files for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts'])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.listAll', {
worktree: 'id:wt-1',
excludePaths: ['/repo/other-worktree']
})
)
expect(runtime.listRuntimeFiles).toHaveBeenCalledWith('id:wt-1', {
excludePaths: ['/repo/other-worktree']
})
expect(response).toMatchObject({ ok: true, result: ['src/index.ts'] })
})
it('lists markdown documents for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listRuntimeMarkdownDocuments: vi.fn().mockResolvedValue([
{
filePath: '/repo/readme.md',
relativePath: 'readme.md',
basename: 'readme.md',
name: 'readme'
}
])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.listMarkdownDocuments', { worktree: 'id:wt-1' })
)
expect(runtime.listRuntimeMarkdownDocuments).toHaveBeenCalledWith('id:wt-1')
expect(response).toMatchObject({ ok: true, result: [{ relativePath: 'readme.md' }] })
})
it('stats a relative path for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
statRuntimeFile: vi.fn().mockResolvedValue({ size: 12, isDirectory: false, mtime: 1 })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.stat', { worktree: 'id:wt-1', relativePath: 'readme.md' })
)
expect(runtime.statRuntimeFile).toHaveBeenCalledWith('id:wt-1', 'readme.md')
expect(response).toMatchObject({ ok: true, result: { isDirectory: false } })
})
})
+284 -2
View File
@@ -1,5 +1,8 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { createFileWatchEventBatcher } from './file-watch-event-batcher'
let filesWatchSubscriptionSeq = 0
const WorktreeSelector = z.object({
worktree: z
@@ -15,7 +18,93 @@ const FileOpen = WorktreeSelector.extend({
.pipe(z.string().min(1, 'Missing relative path'))
})
export const FILE_METHODS: RpcMethod[] = [
const FileTreePath = WorktreeSelector.extend({
relativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string())
})
const FileWrite = FileOpen.extend({
content: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string())
})
const FileWriteBase64 = FileOpen.extend({
contentBase64: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string())
})
const FileWriteBase64Chunk = FileWriteBase64.extend({
append: z.boolean().optional()
})
const FileRename = WorktreeSelector.extend({
oldRelativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing source path')),
newRelativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing destination path'))
})
const FileCopy = WorktreeSelector.extend({
sourceRelativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing source path')),
destinationRelativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing destination path'))
})
const FileCommitUpload = WorktreeSelector.extend({
tempRelativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing temporary path')),
finalRelativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing final path'))
})
const FileDelete = FileOpen.extend({
recursive: z.boolean().optional()
})
const FileSearch = WorktreeSelector.extend({
query: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing search query')),
caseSensitive: z.boolean().optional(),
wholeWord: z.boolean().optional(),
useRegex: z.boolean().optional(),
includePattern: z.string().optional(),
excludePattern: z.string().optional(),
maxResults: z.number().int().positive().optional()
})
const FileListAll = WorktreeSelector.extend({
excludePaths: z.array(z.string()).optional()
})
const FileUnwatch = z.object({
subscriptionId: z
.unknown()
.transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
.pipe(z.string().min(1, 'Missing subscriptionId'))
})
export const FILE_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'files.list',
params: WorktreeSelector,
@@ -32,5 +121,198 @@ export const FILE_METHODS: RpcMethod[] = [
params: FileOpen,
handler: async (params, { runtime }) =>
runtime.readMobileFile(params.worktree, params.relativePath)
}),
defineMethod({
name: 'files.readPreview',
params: FileOpen,
handler: async (params, { runtime }) =>
runtime.readFileExplorerPreview(params.worktree, params.relativePath)
}),
defineMethod({
name: 'files.readDir',
params: FileTreePath,
handler: async (params, { runtime }) =>
runtime.readFileExplorerDir(params.worktree, params.relativePath)
}),
defineMethod({
name: 'files.write',
params: FileWrite,
handler: async (params, { runtime }) =>
runtime.writeFileExplorerFile(params.worktree, params.relativePath, params.content)
}),
defineMethod({
name: 'files.writeBase64',
params: FileWriteBase64,
handler: async (params, { runtime }) =>
runtime.writeFileExplorerFileBase64(
params.worktree,
params.relativePath,
params.contentBase64
)
}),
defineMethod({
name: 'files.writeBase64Chunk',
params: FileWriteBase64Chunk,
handler: async (params, { runtime }) =>
runtime.writeFileExplorerFileBase64Chunk(
params.worktree,
params.relativePath,
params.contentBase64,
params.append === true
)
}),
defineMethod({
name: 'files.createFile',
params: FileOpen,
handler: async (params, { runtime }) =>
runtime.createFileExplorerFile(params.worktree, params.relativePath)
}),
defineMethod({
name: 'files.createDir',
params: FileOpen,
handler: async (params, { runtime }) =>
runtime.createFileExplorerDir(params.worktree, params.relativePath)
}),
defineMethod({
name: 'files.createDirNoClobber',
params: FileOpen,
handler: async (params, { runtime }) =>
runtime.createFileExplorerDirNoClobber(params.worktree, params.relativePath)
}),
defineMethod({
name: 'files.commitUpload',
params: FileCommitUpload,
handler: async (params, { runtime }) =>
runtime.commitFileExplorerUpload(
params.worktree,
params.tempRelativePath,
params.finalRelativePath
)
}),
defineMethod({
name: 'files.rename',
params: FileRename,
handler: async (params, { runtime }) =>
runtime.renameFileExplorerPath(
params.worktree,
params.oldRelativePath,
params.newRelativePath
)
}),
defineMethod({
name: 'files.copy',
params: FileCopy,
handler: async (params, { runtime }) =>
runtime.copyFileExplorerPath(
params.worktree,
params.sourceRelativePath,
params.destinationRelativePath
)
}),
defineMethod({
name: 'files.delete',
params: FileDelete,
handler: async (params, { runtime }) =>
runtime.deleteFileExplorerPath(params.worktree, params.relativePath, params.recursive)
}),
defineMethod({
name: 'files.search',
params: FileSearch,
handler: async (params, { runtime }) =>
runtime.searchRuntimeFiles(params.worktree, {
query: params.query,
caseSensitive: params.caseSensitive,
wholeWord: params.wholeWord,
useRegex: params.useRegex,
includePattern: params.includePattern,
excludePattern: params.excludePattern,
maxResults: params.maxResults
})
}),
defineMethod({
name: 'files.listAll',
params: FileListAll,
handler: async (params, { runtime }) =>
runtime.listRuntimeFiles(params.worktree, { excludePaths: params.excludePaths })
}),
defineMethod({
name: 'files.listMarkdownDocuments',
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.listRuntimeMarkdownDocuments(params.worktree)
}),
defineMethod({
name: 'files.stat',
params: FileTreePath,
handler: async (params, { runtime }) =>
runtime.statRuntimeFile(params.worktree, params.relativePath)
}),
defineStreamingMethod({
name: 'files.watch',
params: WorktreeSelector,
handler: async (params, { runtime, connectionId, signal }, emit) => {
const seq = ++filesWatchSubscriptionSeq
const subscriptionId = `files-watch-${connectionId ?? 'inproc'}-${seq}`
if (signal?.aborted) {
return
}
await new Promise<void>((resolve, reject) => {
let settled = false
let unwatch: (() => void) | null = null
const eventBatcher = createFileWatchEventBatcher(params.worktree, emit)
const finish = (): void => {
if (settled) {
return
}
settled = true
signal?.removeEventListener('abort', handleAbort)
resolve()
}
const cleanup = (): void => {
eventBatcher.flush()
eventBatcher.dispose()
unwatch?.()
emit({ type: 'end' })
finish()
}
const handleAbort = (): void => {
if (unwatch) {
cleanup()
} else {
finish()
}
}
signal?.addEventListener('abort', handleAbort, { once: true })
void runtime
.watchFileExplorer(params.worktree, (events) => {
eventBatcher.push(events)
})
.then((nextUnwatch) => {
if (signal?.aborted || settled) {
// Why: the connection can close while watch setup is still
// resolving. Tear down the late watcher immediately instead of
// registering cleanup on a connection that was already reaped.
nextUnwatch()
return
}
unwatch = nextUnwatch
runtime.registerSubscriptionCleanup(subscriptionId, cleanup, connectionId)
emit({ type: 'ready', subscriptionId })
})
.catch((error) => {
if (!settled) {
signal?.removeEventListener('abort', handleAbort)
reject(error)
}
})
})
}
}),
defineMethod({
name: 'files.unwatch',
params: FileUnwatch,
handler: async (params, { runtime }) => {
runtime.cleanupSubscription(params.subscriptionId)
return { unsubscribed: true }
}
})
]
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { GIT_METHODS } from './git'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('git RPC methods', () => {
it('returns status for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRuntimeGitStatus: vi.fn().mockResolvedValue({
entries: [],
conflictOperation: 'unknown',
branch: 'main',
head: 'abc'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(makeRequest('git.status', { worktree: 'id:wt-1' }))
expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1')
expect(response).toMatchObject({
ok: true,
result: { entries: [], branch: 'main' }
})
})
it('returns a worktree file diff', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRuntimeGitDiff: vi.fn().mockResolvedValue({
kind: 'text',
originalContent: '',
modifiedContent: 'hello',
originalIsBinary: false,
modifiedIsBinary: false
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.diff', {
worktree: 'id:wt-1',
filePath: 'src/index.ts',
staged: false,
compareAgainstHead: true
})
)
expect(runtime.getRuntimeGitDiff).toHaveBeenCalledWith('id:wt-1', 'src/index.ts', false, true)
expect(response).toMatchObject({
ok: true,
result: { kind: 'text', modifiedContent: 'hello' }
})
})
it('routes common mutations to the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
stageRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }),
bulkUnstageRuntimeGitPaths: vi.fn().mockResolvedValue({ ok: true }),
discardRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
await dispatcher.dispatch(
makeRequest('git.stage', { worktree: 'id:wt-1', filePath: 'src/a.ts' })
)
await dispatcher.dispatch(
makeRequest('git.bulkUnstage', { worktree: 'id:wt-1', filePaths: ['src/a.ts', 'b.ts'] })
)
await dispatcher.dispatch(
makeRequest('git.discard', { worktree: 'id:wt-1', filePath: 'src/a.ts' })
)
expect(runtime.stageRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts')
expect(runtime.bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['src/a.ts', 'b.ts'])
expect(runtime.discardRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts')
})
it('routes remote operations to the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
commitRuntimeGit: vi.fn().mockResolvedValue({ success: true }),
pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }),
getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3')
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
await dispatcher.dispatch(
makeRequest('git.commit', { worktree: 'id:wt-1', message: 'feat: test' })
)
await dispatcher.dispatch(
makeRequest('git.push', {
worktree: 'id:wt-1',
publish: true,
pushTarget: { remote: 'origin' }
})
)
const response = await dispatcher.dispatch(
makeRequest('git.remoteFileUrl', {
worktree: 'id:wt-1',
relativePath: 'src/a.ts',
line: 3
})
)
expect(runtime.commitRuntimeGit).toHaveBeenCalledWith('id:wt-1', 'feat: test')
expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, { remote: 'origin' })
expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' })
})
it('rejects branch diff revisions that are not full object ids', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRuntimeGitBranchDiff: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.branchDiff', {
worktree: 'id:wt-1',
filePath: 'src/a.ts',
compare: {
headOid: '--output=/tmp/orca-test',
mergeBase: 'a'.repeat(40)
}
})
)
expect(response.ok).toBe(false)
expect(runtime.getRuntimeGitBranchDiff).not.toHaveBeenCalled()
})
it('rejects branch compare refs that look like git options', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRuntimeGitBranchCompare: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.branchCompare', {
worktree: 'id:wt-1',
baseRef: '--output=/tmp/orca-test'
})
)
expect(response.ok).toBe(false)
expect(runtime.getRuntimeGitBranchCompare).not.toHaveBeenCalled()
})
})
+175
View File
@@ -0,0 +1,175 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
const WorktreeSelector = z.object({
worktree: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing worktree selector'))
})
const GitFilePath = WorktreeSelector.extend({
filePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing file path'))
})
const GitDiff = GitFilePath.extend({
staged: z.boolean(),
compareAgainstHead: z.boolean().optional()
})
const GitBranchCompare = WorktreeSelector.extend({
baseRef: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(
z
.string()
.min(1, 'Missing base ref')
.refine((value) => !value.startsWith('-'), 'Base ref must not start with -')
)
})
const FullGitObjectId = z
.string()
.regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id')
const GitBranchDiff = GitFilePath.extend({
compare: z.object({
baseRef: z.string().optional(),
baseOid: FullGitObjectId.optional(),
headOid: FullGitObjectId,
mergeBase: FullGitObjectId
}),
oldPath: z.string().optional()
})
const GitCommit = WorktreeSelector.extend({
message: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing commit message'))
})
const GitBulkPaths = WorktreeSelector.extend({
filePaths: z.array(z.string())
})
const GitPush = WorktreeSelector.extend({
publish: z.boolean().optional(),
pushTarget: z.unknown().optional()
})
const GitRemoteFileUrl = WorktreeSelector.extend({
relativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing relative path')),
line: z.number().int().min(1)
})
export const GIT_METHODS: RpcMethod[] = [
defineMethod({
name: 'git.status',
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.getRuntimeGitStatus(params.worktree)
}),
defineMethod({
name: 'git.conflictOperation',
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.getRuntimeGitConflictOperation(params.worktree)
}),
defineMethod({
name: 'git.diff',
params: GitDiff,
handler: async (params, { runtime }) =>
runtime.getRuntimeGitDiff(
params.worktree,
params.filePath,
params.staged,
params.compareAgainstHead
)
}),
defineMethod({
name: 'git.branchCompare',
params: GitBranchCompare,
handler: async (params, { runtime }) =>
runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef)
}),
defineMethod({
name: 'git.upstreamStatus',
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.getRuntimeGitUpstreamStatus(params.worktree)
}),
defineMethod({
name: 'git.fetch',
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.fetchRuntimeGit(params.worktree)
}),
defineMethod({
name: 'git.pull',
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.pullRuntimeGit(params.worktree)
}),
defineMethod({
name: 'git.push',
params: GitPush,
handler: async (params, { runtime }) =>
runtime.pushRuntimeGit(params.worktree, params.publish, params.pushTarget as never)
}),
defineMethod({
name: 'git.branchDiff',
params: GitBranchDiff,
handler: async (params, { runtime }) =>
runtime.getRuntimeGitBranchDiff(
params.worktree,
params.compare,
params.filePath,
params.oldPath
)
}),
defineMethod({
name: 'git.commit',
params: GitCommit,
handler: async (params, { runtime }) =>
runtime.commitRuntimeGit(params.worktree, params.message)
}),
defineMethod({
name: 'git.stage',
params: GitFilePath,
handler: async (params, { runtime }) =>
runtime.stageRuntimeGitPath(params.worktree, params.filePath)
}),
defineMethod({
name: 'git.bulkStage',
params: GitBulkPaths,
handler: async (params, { runtime }) =>
runtime.bulkStageRuntimeGitPaths(params.worktree, params.filePaths)
}),
defineMethod({
name: 'git.unstage',
params: GitFilePath,
handler: async (params, { runtime }) =>
runtime.unstageRuntimeGitPath(params.worktree, params.filePath)
}),
defineMethod({
name: 'git.bulkUnstage',
params: GitBulkPaths,
handler: async (params, { runtime }) =>
runtime.bulkUnstageRuntimeGitPaths(params.worktree, params.filePaths)
}),
defineMethod({
name: 'git.discard',
params: GitFilePath,
handler: async (params, { runtime }) =>
runtime.discardRuntimeGitPath(params.worktree, params.filePath)
}),
defineMethod({
name: 'git.remoteFileUrl',
params: GitRemoteFileUrl,
handler: async (params, { runtime }) =>
runtime.getRuntimeGitRemoteFileUrl(params.worktree, params.relativePath, params.line)
})
]
+519
View File
@@ -0,0 +1,519 @@
/* eslint-disable max-lines -- Why: runtime GitHub RPC methods share one dispatcher suite so repo-scoped and Project-scoped contract coverage cannot drift. */
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { GITHUB_METHODS } from './github'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('github RPC methods', () => {
it('resolves the repo slug on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRepoSlug: vi.fn().mockResolvedValue({ owner: 'acme', repo: 'orca' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(makeRequest('github.repoSlug', { repo: 'repo-1' }))
expect(runtime.getRepoSlug).toHaveBeenCalledWith('repo-1')
expect(response).toMatchObject({
ok: true,
result: { owner: 'acme', repo: 'orca' }
})
})
it('fetches GitHub rate limits on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getGitHubRateLimit: vi.fn().mockResolvedValue({ ok: true, snapshot: { core: {} } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(makeRequest('github.rateLimit', { force: true }))
expect(runtime.getGitHubRateLimit).toHaveBeenCalledWith({ force: true })
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('lists work items on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listRepoWorkItems: vi.fn().mockResolvedValue({ items: [] })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.listWorkItems', { repo: 'repo-1', limit: 10, query: 'is:pr' })
)
expect(runtime.listRepoWorkItems).toHaveBeenCalledWith('repo-1', 10, 'is:pr', undefined)
expect(response).toMatchObject({ ok: true, result: { items: [] } })
})
it('looks up a single work item on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRepoWorkItem: vi.fn().mockResolvedValue({ number: 12, type: 'pr' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.workItem', { repo: 'repo-1', number: 12, type: 'pr' })
)
expect(runtime.getRepoWorkItem).toHaveBeenCalledWith('repo-1', 12, 'pr')
expect(response).toMatchObject({ ok: true, result: { number: 12, type: 'pr' } })
})
it('looks up a single work item by explicit owner/repo on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRepoWorkItemByOwnerRepo: vi.fn().mockResolvedValue({ number: 12, type: 'pr' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.workItemByOwnerRepo', {
repo: 'repo-1',
owner: 'acme',
ownerRepo: 'orca',
number: 12,
type: 'pr'
})
)
expect(runtime.getRepoWorkItemByOwnerRepo).toHaveBeenCalledWith(
'repo-1',
{ owner: 'acme', repo: 'orca' },
12,
'pr'
)
expect(response).toMatchObject({ ok: true, result: { number: 12, type: 'pr' } })
})
it('fetches work item details on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRepoWorkItemDetails: vi.fn().mockResolvedValue({ body: 'Details' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.workItemDetails', { repo: 'repo-1', number: 12, type: 'issue' })
)
expect(runtime.getRepoWorkItemDetails).toHaveBeenCalledWith('repo-1', 12, 'issue')
expect(response).toMatchObject({ ok: true, result: { body: 'Details' } })
})
it('counts work items on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
countRepoWorkItems: vi.fn().mockResolvedValue(3)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.countWorkItems', { repo: 'repo-1', query: 'is:open' })
)
expect(runtime.countRepoWorkItems).toHaveBeenCalledWith('repo-1', 'is:open')
expect(response).toMatchObject({ ok: true, result: 3 })
})
it('lists repo issue metadata on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listRepoLabels: vi.fn().mockResolvedValue(['bug']),
listRepoAssignableUsers: vi.fn().mockResolvedValue([{ login: 'octo' }])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const labels = await dispatcher.dispatch(makeRequest('github.listLabels', { repo: 'repo-1' }))
const users = await dispatcher.dispatch(
makeRequest('github.listAssignableUsers', { repo: 'repo-1' })
)
expect(runtime.listRepoLabels).toHaveBeenCalledWith('repo-1')
expect(runtime.listRepoAssignableUsers).toHaveBeenCalledWith('repo-1')
expect(labels).toMatchObject({ ok: true, result: ['bug'] })
expect(users).toMatchObject({ ok: true, result: [{ login: 'octo' }] })
})
it('fetches PR checks on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRepoPRChecks: vi.fn().mockResolvedValue([])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.prChecks', {
repo: 'repo-1',
prNumber: 7,
headSha: 'abc123',
noCache: true
})
)
expect(runtime.getRepoPRChecks).toHaveBeenCalledWith('repo-1', 7, 'abc123', {
noCache: true
})
expect(response).toMatchObject({ ok: true, result: [] })
})
it('fetches PR file contents on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRepoPRFileContents: vi.fn().mockResolvedValue({
original: '',
modified: 'new',
originalIsBinary: false,
modifiedIsBinary: false
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.prFileContents', {
repo: 'repo-1',
prNumber: 7,
path: 'src/app.ts',
status: 'modified',
headSha: 'head',
baseSha: 'base'
})
)
expect(runtime.getRepoPRFileContents).toHaveBeenCalledWith('repo-1', {
prNumber: 7,
path: 'src/app.ts',
oldPath: undefined,
status: 'modified',
headSha: 'head',
baseSha: 'base'
})
expect(response).toMatchObject({ ok: true, result: { modified: 'new' } })
})
it('resolves review threads on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
resolveRepoReviewThread: vi.fn().mockResolvedValue(true)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.resolveReviewThread', {
repo: 'repo-1',
threadId: 'PRRT_1',
resolve: true
})
)
expect(runtime.resolveRepoReviewThread).toHaveBeenCalledWith('repo-1', 'PRRT_1', true)
expect(response).toMatchObject({ ok: true, result: true })
})
it('updates PR titles on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateRepoPRTitle: vi.fn().mockResolvedValue(true)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.updatePRTitle', {
repo: 'repo-1',
prNumber: 7,
title: 'New title'
})
)
expect(runtime.updateRepoPRTitle).toHaveBeenCalledWith('repo-1', 7, 'New title')
expect(response).toMatchObject({ ok: true, result: true })
})
it('merges PRs on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
mergeRepoPR: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.mergePR', {
repo: 'repo-1',
prNumber: 7,
method: 'squash'
})
)
expect(runtime.mergeRepoPR).toHaveBeenCalledWith('repo-1', 7, 'squash')
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('creates issues on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
createRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 3, url: 'https://gh/3' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.createIssue', {
repo: 'repo-1',
title: 'Bug',
body: 'Body'
})
)
expect(runtime.createRepoIssue).toHaveBeenCalledWith('repo-1', 'Bug', 'Body')
expect(response).toMatchObject({ ok: true, result: { ok: true, number: 3 } })
})
it('updates issues on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateRepoIssue: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.updateIssue', {
repo: 'repo-1',
number: 3,
updates: { state: 'closed', addLabels: ['bug'] }
})
)
expect(runtime.updateRepoIssue).toHaveBeenCalledWith('repo-1', 3, {
state: 'closed',
addLabels: ['bug']
})
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('adds issue comments on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
addRepoIssueComment: vi.fn().mockResolvedValue({ ok: true, comment: { id: 1 } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.addIssueComment', {
repo: 'repo-1',
number: 3,
body: 'Looks good',
type: 'pr'
})
)
expect(runtime.addRepoIssueComment).toHaveBeenCalledWith('repo-1', 3, 'Looks good')
expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 1 } } })
})
it('adds PR review comments on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
addRepoPRReviewComment: vi.fn().mockResolvedValue({ ok: true, comment: { id: 2 } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.addPRReviewComment', {
repo: 'repo-1',
prNumber: 7,
commitId: 'head',
path: 'src/app.ts',
line: 12,
startLine: 10,
body: 'Please tweak'
})
)
expect(runtime.addRepoPRReviewComment).toHaveBeenCalledWith('repo-1', {
prNumber: 7,
commitId: 'head',
path: 'src/app.ts',
line: 12,
startLine: 10,
body: 'Please tweak'
})
expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 2 } } })
})
it('adds PR review comment replies on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
addRepoPRReviewCommentReply: vi.fn().mockResolvedValue({ ok: true, comment: { id: 4 } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.addPRReviewCommentReply', {
repo: 'repo-1',
prNumber: 7,
commentId: 2,
body: 'Done',
threadId: 'PRRT_1',
path: 'src/app.ts',
line: 12
})
)
expect(runtime.addRepoPRReviewCommentReply).toHaveBeenCalledWith('repo-1', {
prNumber: 7,
commentId: 2,
body: 'Done',
threadId: 'PRRT_1',
path: 'src/app.ts',
line: 12
})
expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 4 } } })
})
it('fetches GitHub project views on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listGitHubProjectViews: vi.fn().mockResolvedValue({ ok: true, views: [] })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.project.listViews', {
owner: 'acme',
ownerType: 'organization',
projectNumber: 1
})
)
expect(runtime.listGitHubProjectViews).toHaveBeenCalledWith({
owner: 'acme',
ownerType: 'organization',
projectNumber: 1
})
expect(response).toMatchObject({ ok: true, result: { ok: true, views: [] } })
})
it('lists slug-addressed issue metadata on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listGitHubLabelsBySlug: vi.fn().mockResolvedValue({ ok: true, labels: ['bug'] }),
listGitHubAssignableUsersBySlug: vi
.fn()
.mockResolvedValue({ ok: true, users: [{ login: 'octo' }] }),
listGitHubIssueTypesBySlug: vi.fn().mockResolvedValue({ ok: true, types: [{ id: 'it-1' }] })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const labels = await dispatcher.dispatch(
makeRequest('github.project.listLabelsBySlug', { owner: 'acme', repo: 'orca' })
)
const users = await dispatcher.dispatch(
makeRequest('github.project.listAssignableUsersBySlug', {
owner: 'acme',
repo: 'orca',
seedLogins: ['octo']
})
)
const issueTypes = await dispatcher.dispatch(
makeRequest('github.project.listIssueTypesBySlug', { owner: 'acme', repo: 'orca' })
)
expect(runtime.listGitHubLabelsBySlug).toHaveBeenCalledWith({ owner: 'acme', repo: 'orca' })
expect(runtime.listGitHubAssignableUsersBySlug).toHaveBeenCalledWith({
owner: 'acme',
repo: 'orca',
seedLogins: ['octo']
})
expect(runtime.listGitHubIssueTypesBySlug).toHaveBeenCalledWith({ owner: 'acme', repo: 'orca' })
expect(labels).toMatchObject({ ok: true, result: { ok: true, labels: ['bug'] } })
expect(users).toMatchObject({ ok: true, result: { ok: true, users: [{ login: 'octo' }] } })
expect(issueTypes).toMatchObject({ ok: true, result: { ok: true, types: [{ id: 'it-1' }] } })
})
it('fetches GitHub project tables on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getGitHubProjectViewTable: vi.fn().mockResolvedValue({ ok: true, data: { rows: [] } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.project.viewTable', {
owner: 'acme',
ownerType: 'organization',
projectNumber: 1,
viewId: 'view-1',
queryOverride: 'is:open'
})
)
expect(runtime.getGitHubProjectViewTable).toHaveBeenCalledWith({
owner: 'acme',
ownerType: 'organization',
projectNumber: 1,
viewId: 'view-1',
viewNumber: undefined,
viewName: undefined,
queryOverride: 'is:open'
})
expect(response).toMatchObject({ ok: true, result: { ok: true, data: { rows: [] } } })
})
it('updates GitHub project item fields on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateGitHubProjectItemField: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.project.updateItemField', {
projectId: 'project-1',
itemId: 'item-1',
fieldId: 'field-1',
value: { kind: 'text', text: 'Now' }
})
)
expect(runtime.updateGitHubProjectItemField).toHaveBeenCalledWith({
projectId: 'project-1',
itemId: 'item-1',
fieldId: 'field-1',
value: { kind: 'text', text: 'Now' }
})
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('updates GitHub project issue types on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateGitHubIssueTypeBySlug: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.project.updateIssueTypeBySlug', {
owner: 'acme',
repo: 'orca',
number: 9,
issueTypeId: null
})
)
expect(runtime.updateGitHubIssueTypeBySlug).toHaveBeenCalledWith({
owner: 'acme',
repo: 'orca',
number: 9,
issueTypeId: null
})
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
})
+442
View File
@@ -0,0 +1,442 @@
/* eslint-disable max-lines -- Why: GitHub runtime RPC keeps related repo, project, and mutation schemas beside their handlers so the method contract stays reviewable in one place. */
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
const RepoSelector = z.object({
repo: requiredString('Missing repo selector')
})
const WorkItemsList = RepoSelector.extend({
limit: OptionalFiniteNumber,
query: OptionalString,
before: OptionalString
})
const WorkItem = RepoSelector.extend({
number: z.number().int().positive(),
type: z.enum(['issue', 'pr']).optional()
})
const WorkItemByOwnerRepo = RepoSelector.extend({
owner: requiredString('Missing owner'),
ownerRepo: requiredString('Missing repo'),
number: z.number().int().positive(),
type: z.enum(['issue', 'pr'])
})
const WorkItemDetails = WorkItem
const WorkItemsCount = RepoSelector.extend({
query: OptionalString
})
const RateLimit = z.object({
force: z.boolean().optional()
})
const SlugRepo = z.object({
owner: requiredString('Missing owner'),
repo: requiredString('Missing repo')
})
const SlugAssignableUsers = SlugRepo.extend({
seedLogins: z.array(z.string()).optional()
})
const PrForBranch = RepoSelector.extend({
branch: requiredString('Missing branch'),
linkedPRNumber: z.number().int().positive().nullable().optional()
})
const Issue = RepoSelector.extend({
number: z.number().int().positive()
})
const PullRequest = RepoSelector.extend({
prNumber: z.number().int().positive(),
noCache: z.boolean().optional()
})
const PullRequestChecks = PullRequest.extend({
headSha: OptionalString
})
const PullRequestFileContents = RepoSelector.extend({
prNumber: z.number().int().positive(),
path: requiredString('Missing file path'),
oldPath: OptionalString,
status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']),
headSha: requiredString('Missing head SHA'),
baseSha: requiredString('Missing base SHA')
})
const ReviewThread = RepoSelector.extend({
threadId: requiredString('Missing thread ID'),
resolve: z.boolean()
})
const UpdatePrTitle = RepoSelector.extend({
prNumber: z.number().int().positive(),
title: requiredString('Missing title')
})
const MergePr = RepoSelector.extend({
prNumber: z.number().int().positive(),
method: z.enum(['merge', 'squash', 'rebase']).optional()
})
const CreateIssue = RepoSelector.extend({
title: requiredString('Missing title'),
body: z.string()
})
const IssueUpdate = z.object({
state: z.enum(['open', 'closed']).optional(),
title: OptionalString,
body: OptionalString,
addLabels: z.array(z.string()).optional(),
removeLabels: z.array(z.string()).optional(),
addAssignees: z.array(z.string()).optional(),
removeAssignees: z.array(z.string()).optional()
})
const UpdateIssue = RepoSelector.extend({
number: z.number().int().positive(),
updates: IssueUpdate
})
const IssueComment = RepoSelector.extend({
number: z.number().int().positive(),
body: requiredString('Comment body required'),
type: z.enum(['issue', 'pr']).optional()
})
const PRReviewComment = RepoSelector.extend({
prNumber: z.number().int().positive(),
commitId: requiredString('Missing PR head SHA'),
path: requiredString('File path required'),
line: z.number().int().positive(),
startLine: z.number().int().positive().optional(),
body: requiredString('Comment body required')
})
const PRReviewCommentReply = RepoSelector.extend({
prNumber: z.number().int().positive(),
commentId: z.number().int().positive(),
body: requiredString('Comment body required'),
threadId: OptionalString,
path: OptionalString,
line: z.number().int().positive().optional()
})
const ProjectOwnerType = z.enum(['organization', 'user'])
const ProjectViewTable = z.object({
owner: requiredString('Missing owner'),
ownerType: ProjectOwnerType,
projectNumber: z.number().int().positive(),
viewId: OptionalString,
viewNumber: z.number().int().positive().optional(),
viewName: OptionalString,
queryOverride: OptionalString
})
const ProjectRef = z.object({
input: requiredString('Missing project reference')
})
const ProjectViews = z.object({
owner: requiredString('Missing owner'),
ownerType: ProjectOwnerType,
projectNumber: z.number().int().positive()
})
const ProjectItemField = z.object({
projectId: requiredString('Missing project ID'),
itemId: requiredString('Missing item ID'),
fieldId: requiredString('Missing field ID'),
value: z.any()
})
const ClearProjectItemField = z.object({
projectId: requiredString('Missing project ID'),
itemId: requiredString('Missing item ID'),
fieldId: requiredString('Missing field ID')
})
const SlugIssueUpdate = z.object({
owner: requiredString('Missing owner'),
repo: requiredString('Missing repo'),
number: z.number().int().positive(),
updates: IssueUpdate
})
const SlugPullRequestUpdate = z.object({
owner: requiredString('Missing owner'),
repo: requiredString('Missing repo'),
number: z.number().int().positive(),
updates: z.object({
title: OptionalString,
body: OptionalString
})
})
const SlugIssueTypeUpdate = z.object({
owner: requiredString('Missing owner'),
repo: requiredString('Missing repo'),
number: z.number().int().positive(),
issueTypeId: z.string().nullable()
})
const SlugIssueComment = z.object({
owner: requiredString('Missing owner'),
repo: requiredString('Missing repo'),
number: z.number().int().positive(),
body: requiredString('Comment body required')
})
const SlugIssueCommentEdit = z.object({
owner: requiredString('Missing owner'),
repo: requiredString('Missing repo'),
commentId: z.number().int().positive(),
body: requiredString('Comment body required')
})
const SlugIssueCommentDelete = z.object({
owner: requiredString('Missing owner'),
repo: requiredString('Missing repo'),
commentId: z.number().int().positive()
})
export const GITHUB_METHODS: RpcMethod[] = [
defineMethod({
name: 'github.repoSlug',
params: RepoSelector,
handler: async (params, { runtime }) => runtime.getRepoSlug(params.repo)
}),
defineMethod({
name: 'github.rateLimit',
params: RateLimit,
handler: async (params, { runtime }) => runtime.getGitHubRateLimit(params)
}),
defineMethod({
name: 'github.listWorkItems',
params: WorkItemsList,
handler: async (params, { runtime }) =>
runtime.listRepoWorkItems(params.repo, params.limit, params.query, params.before)
}),
defineMethod({
name: 'github.countWorkItems',
params: WorkItemsCount,
handler: async (params, { runtime }) => runtime.countRepoWorkItems(params.repo, params.query)
}),
defineMethod({
name: 'github.listLabels',
params: RepoSelector,
handler: async (params, { runtime }) => runtime.listRepoLabels(params.repo)
}),
defineMethod({
name: 'github.listAssignableUsers',
params: RepoSelector,
handler: async (params, { runtime }) => runtime.listRepoAssignableUsers(params.repo)
}),
defineMethod({
name: 'github.workItem',
params: WorkItem,
handler: async (params, { runtime }) =>
runtime.getRepoWorkItem(params.repo, params.number, params.type)
}),
defineMethod({
name: 'github.workItemByOwnerRepo',
params: WorkItemByOwnerRepo,
handler: async (params, { runtime }) =>
runtime.getRepoWorkItemByOwnerRepo(
params.repo,
{ owner: params.owner, repo: params.ownerRepo },
params.number,
params.type
)
}),
defineMethod({
name: 'github.workItemDetails',
params: WorkItemDetails,
handler: async (params, { runtime }) =>
runtime.getRepoWorkItemDetails(params.repo, params.number, params.type)
}),
defineMethod({
name: 'github.prForBranch',
params: PrForBranch,
handler: async (params, { runtime }) =>
runtime.getRepoPRForBranch(params.repo, params.branch, params.linkedPRNumber)
}),
defineMethod({
name: 'github.issue',
params: Issue,
handler: async (params, { runtime }) => runtime.getRepoIssue(params.repo, params.number)
}),
defineMethod({
name: 'github.prChecks',
params: PullRequestChecks,
handler: async (params, { runtime }) =>
runtime.getRepoPRChecks(params.repo, params.prNumber, params.headSha, {
noCache: params.noCache
})
}),
defineMethod({
name: 'github.prComments',
params: PullRequest,
handler: async (params, { runtime }) =>
runtime.getRepoPRComments(params.repo, params.prNumber, { noCache: params.noCache })
}),
defineMethod({
name: 'github.prFileContents',
params: PullRequestFileContents,
handler: async (params, { runtime }) =>
runtime.getRepoPRFileContents(params.repo, {
prNumber: params.prNumber,
path: params.path,
oldPath: params.oldPath,
status: params.status,
headSha: params.headSha,
baseSha: params.baseSha
})
}),
defineMethod({
name: 'github.resolveReviewThread',
params: ReviewThread,
handler: async (params, { runtime }) =>
runtime.resolveRepoReviewThread(params.repo, params.threadId, params.resolve)
}),
defineMethod({
name: 'github.updatePRTitle',
params: UpdatePrTitle,
handler: async (params, { runtime }) =>
runtime.updateRepoPRTitle(params.repo, params.prNumber, params.title)
}),
defineMethod({
name: 'github.mergePR',
params: MergePr,
handler: async (params, { runtime }) =>
runtime.mergeRepoPR(params.repo, params.prNumber, params.method)
}),
defineMethod({
name: 'github.createIssue',
params: CreateIssue,
handler: async (params, { runtime }) =>
runtime.createRepoIssue(params.repo, params.title, params.body)
}),
defineMethod({
name: 'github.updateIssue',
params: UpdateIssue,
handler: async (params, { runtime }) =>
runtime.updateRepoIssue(params.repo, params.number, params.updates)
}),
defineMethod({
name: 'github.addIssueComment',
params: IssueComment,
handler: async (params, { runtime }) =>
runtime.addRepoIssueComment(params.repo, params.number, params.body)
}),
defineMethod({
name: 'github.addPRReviewComment',
params: PRReviewComment,
handler: async (params, { runtime }) =>
runtime.addRepoPRReviewComment(params.repo, {
prNumber: params.prNumber,
commitId: params.commitId,
path: params.path,
line: params.line,
startLine: params.startLine,
body: params.body
})
}),
defineMethod({
name: 'github.addPRReviewCommentReply',
params: PRReviewCommentReply,
handler: async (params, { runtime }) =>
runtime.addRepoPRReviewCommentReply(params.repo, {
prNumber: params.prNumber,
commentId: params.commentId,
body: params.body,
threadId: params.threadId,
path: params.path,
line: params.line
})
}),
defineMethod({
name: 'github.project.listAccessible',
params: z.object({}),
handler: async (_params, { runtime }) => runtime.listGitHubProjects()
}),
defineMethod({
name: 'github.project.listLabelsBySlug',
params: SlugRepo,
handler: async (params, { runtime }) => runtime.listGitHubLabelsBySlug(params)
}),
defineMethod({
name: 'github.project.listAssignableUsersBySlug',
params: SlugAssignableUsers,
handler: async (params, { runtime }) => runtime.listGitHubAssignableUsersBySlug(params)
}),
defineMethod({
name: 'github.project.listIssueTypesBySlug',
params: SlugRepo,
handler: async (params, { runtime }) => runtime.listGitHubIssueTypesBySlug(params)
}),
defineMethod({
name: 'github.project.resolveRef',
params: ProjectRef,
handler: async (params, { runtime }) => runtime.resolveGitHubProjectRef(params)
}),
defineMethod({
name: 'github.project.listViews',
params: ProjectViews,
handler: async (params, { runtime }) => runtime.listGitHubProjectViews(params)
}),
defineMethod({
name: 'github.project.viewTable',
params: ProjectViewTable,
handler: async (params, { runtime }) => runtime.getGitHubProjectViewTable(params)
}),
defineMethod({
name: 'github.project.updateItemField',
params: ProjectItemField,
handler: async (params, { runtime }) => runtime.updateGitHubProjectItemField(params)
}),
defineMethod({
name: 'github.project.clearItemField',
params: ClearProjectItemField,
handler: async (params, { runtime }) => runtime.clearGitHubProjectItemField(params)
}),
defineMethod({
name: 'github.project.updateIssueBySlug',
params: SlugIssueUpdate,
handler: async (params, { runtime }) => runtime.updateGitHubIssueBySlug(params)
}),
defineMethod({
name: 'github.project.updatePullRequestBySlug',
params: SlugPullRequestUpdate,
handler: async (params, { runtime }) => runtime.updateGitHubPullRequestBySlug(params)
}),
defineMethod({
name: 'github.project.updateIssueTypeBySlug',
params: SlugIssueTypeUpdate,
handler: async (params, { runtime }) => runtime.updateGitHubIssueTypeBySlug(params)
}),
defineMethod({
name: 'github.project.addIssueCommentBySlug',
params: SlugIssueComment,
handler: async (params, { runtime }) => runtime.addGitHubIssueCommentBySlug(params)
}),
defineMethod({
name: 'github.project.updateIssueCommentBySlug',
params: SlugIssueCommentEdit,
handler: async (params, { runtime }) => runtime.updateGitHubIssueCommentBySlug(params)
}),
defineMethod({
name: 'github.project.deleteIssueCommentBySlug',
params: SlugIssueCommentDelete,
handler: async (params, { runtime }) => runtime.deleteGitHubIssueCommentBySlug(params)
})
]
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { HOSTED_REVIEW_METHODS } from './hosted-review'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('hosted review RPC methods', () => {
it('fetches branch review status on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getHostedReviewForBranch: vi.fn().mockResolvedValue({
provider: 'github',
number: 12,
title: 'Feature',
state: 'open',
url: 'https://github.com/acme/orca/pull/12',
status: 'success',
updatedAt: '2026-05-10T00:00:00.000Z',
mergeable: 'MERGEABLE'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: HOSTED_REVIEW_METHODS })
const response = await dispatcher.dispatch(
makeRequest('hostedReview.forBranch', {
repo: 'C:\\repo',
branch: 'feature/windows',
linkedGitHubPR: 12
})
)
expect(runtime.getHostedReviewForBranch).toHaveBeenCalledWith({
repoSelector: 'C:\\repo',
branch: 'feature/windows',
linkedGitHubPR: 12,
linkedGitLabMR: null,
linkedBitbucketPR: null
})
expect(response).toMatchObject({
ok: true,
result: { provider: 'github', number: 12 }
})
})
})
@@ -0,0 +1,26 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { requiredString } from '../schemas'
const HostedReviewForBranch = z.object({
repo: requiredString('Missing repo selector'),
branch: requiredString('Missing branch'),
linkedGitHubPR: z.number().int().positive().nullable().optional(),
linkedGitLabMR: z.number().int().positive().nullable().optional(),
linkedBitbucketPR: z.number().int().positive().nullable().optional()
})
export const HOSTED_REVIEW_METHODS: RpcMethod[] = [
defineMethod({
name: 'hostedReview.forBranch',
params: HostedReviewForBranch,
handler: async (params, { runtime }) =>
runtime.getHostedReviewForBranch({
repoSelector: params.repo,
branch: params.branch,
linkedGitHubPR: params.linkedGitHubPR ?? null,
linkedGitLabMR: params.linkedGitLabMR ?? null,
linkedBitbucketPR: params.linkedBitbucketPR ?? null
})
})
]
+8
View File
@@ -12,6 +12,10 @@ import { ACCOUNT_METHODS } from './accounts'
import { COMPUTER_METHODS } from './computer'
import { SESSION_TAB_METHODS } from './session-tabs'
import { FILE_METHODS } from './files'
import { GIT_METHODS } from './git'
import { GITHUB_METHODS } from './github'
import { HOSTED_REVIEW_METHODS } from './hosted-review'
import { LINEAR_METHODS } from './linear'
import { NOTE_METHODS } from './notes'
import { SPEECH_METHODS } from './speech'
@@ -32,6 +36,10 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...COMPUTER_METHODS,
...SESSION_TAB_METHODS,
...FILE_METHODS,
...GIT_METHODS,
...GITHUB_METHODS,
...HOSTED_REVIEW_METHODS,
...LINEAR_METHODS,
...NOTE_METHODS,
...SPEECH_METHODS
]
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { LINEAR_METHODS } from './linear'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('linear RPC methods', () => {
it('routes Linear account methods to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearStatus: vi.fn().mockResolvedValue({ connected: true, viewer: null }),
linearTestConnection: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }),
linearConnect: vi.fn().mockResolvedValue({ ok: true, viewer: { displayName: 'Ada' } }),
linearDisconnect: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS })
await dispatcher.dispatch(makeRequest('linear.status'))
await dispatcher.dispatch(makeRequest('linear.testConnection'))
await dispatcher.dispatch(makeRequest('linear.connect', { apiKey: 'lin_api_key' }))
await dispatcher.dispatch(makeRequest('linear.disconnect'))
expect(runtime.linearStatus).toHaveBeenCalled()
expect(runtime.linearTestConnection).toHaveBeenCalled()
expect(runtime.linearConnect).toHaveBeenCalledWith('lin_api_key')
expect(runtime.linearDisconnect).toHaveBeenCalled()
})
it('routes Linear issue queries and mutations to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearSearchIssues: vi.fn().mockResolvedValue([{ id: 'issue-1' }]),
linearListIssues: vi.fn().mockResolvedValue([{ id: 'issue-2' }]),
linearGetIssue: vi.fn().mockResolvedValue({ id: 'issue-3' }),
linearCreateIssue: vi.fn().mockResolvedValue({ ok: true, id: 'issue-4' }),
linearUpdateIssue: vi.fn().mockResolvedValue({ ok: true }),
linearAddIssueComment: vi.fn().mockResolvedValue({ ok: true, id: 'comment-1' }),
linearIssueComments: vi.fn().mockResolvedValue([{ id: 'comment-2' }])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS })
await dispatcher.dispatch(makeRequest('linear.searchIssues', { query: 'bug', limit: 30 }))
await dispatcher.dispatch(makeRequest('linear.listIssues', { filter: 'assigned', limit: 20 }))
await dispatcher.dispatch(makeRequest('linear.getIssue', { id: 'issue-3' }))
await dispatcher.dispatch(
makeRequest('linear.createIssue', {
teamId: 'team-1',
title: 'Fix bug',
description: 'Details'
})
)
await dispatcher.dispatch(
makeRequest('linear.updateIssue', {
id: 'issue-3',
updates: { stateId: 'state-1', assigneeId: null, priority: 2, labelIds: ['label-1'] }
})
)
await dispatcher.dispatch(
makeRequest('linear.addIssueComment', { issueId: 'issue-3', body: 'Looks good' })
)
await dispatcher.dispatch(makeRequest('linear.issueComments', { issueId: 'issue-3' }))
expect(runtime.linearSearchIssues).toHaveBeenCalledWith('bug', 30)
expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20)
expect(runtime.linearGetIssue).toHaveBeenCalledWith('issue-3')
expect(runtime.linearCreateIssue).toHaveBeenCalledWith('team-1', 'Fix bug', 'Details')
expect(runtime.linearUpdateIssue).toHaveBeenCalledWith('issue-3', {
stateId: 'state-1',
assigneeId: null,
priority: 2,
labelIds: ['label-1']
})
expect(runtime.linearAddIssueComment).toHaveBeenCalledWith('issue-3', 'Looks good')
expect(runtime.linearIssueComments).toHaveBeenCalledWith('issue-3')
})
it('routes Linear metadata requests to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]),
linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]),
linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]),
linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_METHODS })
await dispatcher.dispatch(makeRequest('linear.listTeams'))
await dispatcher.dispatch(makeRequest('linear.teamStates', { teamId: 'team-1' }))
await dispatcher.dispatch(makeRequest('linear.teamLabels', { teamId: 'team-1' }))
await dispatcher.dispatch(makeRequest('linear.teamMembers', { teamId: 'team-1' }))
expect(runtime.linearListTeams).toHaveBeenCalled()
expect(runtime.linearTeamStates).toHaveBeenCalledWith('team-1')
expect(runtime.linearTeamLabels).toHaveBeenCalledWith('team-1')
expect(runtime.linearTeamMembers).toHaveBeenCalledWith('team-1')
})
})
+136
View File
@@ -0,0 +1,136 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const
const Connect = z.object({
apiKey: requiredString('Invalid API key')
})
const SearchIssues = z.object({
query: requiredString('Missing query'),
limit: OptionalFiniteNumber
})
const ListIssues = z
.object({
filter: z.enum(VALID_FILTERS).optional(),
limit: OptionalFiniteNumber
})
.optional()
const CreateIssue = z.object({
teamId: requiredString('Team ID is required'),
title: requiredString('Title is required'),
description: OptionalString
})
const IssueId = z.object({
id: requiredString('Issue ID is required')
})
const IssueComment = z.object({
issueId: requiredString('Issue ID is required'),
body: requiredString('Comment body is required')
})
const TeamId = z.object({
teamId: requiredString('Team ID is required')
})
const IssueUpdate = z.object({
id: requiredString('Issue ID is required'),
updates: z.object({
stateId: OptionalString,
title: OptionalString,
assigneeId: z.union([z.string(), z.null()]).optional(),
priority: z.number().int().min(0).max(4).optional(),
labelIds: z.array(z.string()).optional()
})
})
export const LINEAR_METHODS: RpcMethod[] = [
defineMethod({
name: 'linear.connect',
params: Connect,
handler: async (params, { runtime }) => runtime.linearConnect(params.apiKey.trim())
}),
defineMethod({
name: 'linear.disconnect',
params: null,
handler: async (_params, { runtime }) => runtime.linearDisconnect()
}),
defineMethod({
name: 'linear.status',
params: null,
handler: async (_params, { runtime }) => runtime.linearStatus()
}),
defineMethod({
name: 'linear.testConnection',
params: null,
handler: async (_params, { runtime }) => runtime.linearTestConnection()
}),
defineMethod({
name: 'linear.searchIssues',
params: SearchIssues,
handler: async (params, { runtime }) => runtime.linearSearchIssues(params.query, params.limit)
}),
defineMethod({
name: 'linear.listIssues',
params: ListIssues,
handler: async (params, { runtime }) => runtime.linearListIssues(params?.filter, params?.limit)
}),
defineMethod({
name: 'linear.createIssue',
params: CreateIssue,
handler: async (params, { runtime }) =>
runtime.linearCreateIssue(
params.teamId.trim(),
params.title.trim(),
params.description?.trim() || undefined
)
}),
defineMethod({
name: 'linear.getIssue',
params: IssueId,
handler: async (params, { runtime }) => runtime.linearGetIssue(params.id.trim())
}),
defineMethod({
name: 'linear.updateIssue',
params: IssueUpdate,
handler: async (params, { runtime }) =>
runtime.linearUpdateIssue(params.id.trim(), params.updates)
}),
defineMethod({
name: 'linear.addIssueComment',
params: IssueComment,
handler: async (params, { runtime }) =>
runtime.linearAddIssueComment(params.issueId.trim(), params.body.trim())
}),
defineMethod({
name: 'linear.issueComments',
params: z.object({ issueId: requiredString('Issue ID is required') }),
handler: async (params, { runtime }) => runtime.linearIssueComments(params.issueId.trim())
}),
defineMethod({
name: 'linear.listTeams',
params: null,
handler: async (_params, { runtime }) => runtime.linearListTeams()
}),
defineMethod({
name: 'linear.teamStates',
params: TeamId,
handler: async (params, { runtime }) => runtime.linearTeamStates(params.teamId.trim())
}),
defineMethod({
name: 'linear.teamLabels',
params: TeamId,
handler: async (params, { runtime }) => runtime.linearTeamLabels(params.teamId.trim())
}),
defineMethod({
name: 'linear.teamMembers',
params: TeamId,
handler: async (params, { runtime }) => runtime.linearTeamMembers(params.teamId.trim())
})
]
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcRequest } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { NOTE_METHODS } from './notes'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('notes RPC methods', () => {
it('routes note reads through the selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }),
showNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS })
await dispatcher.dispatch(makeRequest('note.list', { worktree: 'id:wt-1', limit: 50 }))
await dispatcher.dispatch(makeRequest('note.show', { worktree: 'id:wt-1', note: 'note-1' }))
expect(runtime.listNotes).toHaveBeenCalledWith({ worktreeSelector: 'id:wt-1', limit: 50 })
expect(runtime.showNote).toHaveBeenCalledWith({
worktreeSelector: 'id:wt-1',
note: 'note-1'
})
})
it('routes note mutations through the selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
createNote: vi.fn().mockResolvedValue({ note: { id: 'created' }, linkKind: 'active' }),
saveNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: 'active' }),
renameNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }),
deleteNote: vi.fn().mockResolvedValue({ noteId: 'note-1', projectId: 'repo-1' }),
appendNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }),
linkNote: vi.fn().mockResolvedValue({
noteId: 'note-1',
projectId: 'repo-1',
worktreeId: 'wt-1',
kind: 'active',
createdAt: 'now'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS })
await dispatcher.dispatch(
makeRequest('note.create', {
worktree: 'id:wt-1',
title: 'Plan',
bodyMarkdown: 'body',
makeActive: true
})
)
await dispatcher.dispatch(
makeRequest('note.save', {
worktree: 'id:wt-1',
note: 'note-1',
title: 'Plan v2',
bodyMarkdown: 'updated',
revision: 2,
makeActive: true
})
)
await dispatcher.dispatch(
makeRequest('note.rename', { worktree: 'id:wt-1', note: 'note-1', title: 'Renamed' })
)
await dispatcher.dispatch(makeRequest('note.delete', { worktree: 'id:wt-1', note: 'note-1' }))
await dispatcher.dispatch(
makeRequest('note.append', {
worktree: 'id:wt-1',
note: 'note-1',
bodyMarkdown: 'more',
makeActive: true
})
)
await dispatcher.dispatch(
makeRequest('note.link', { worktree: 'id:wt-1', note: 'note-1', kind: 'active' })
)
expect(runtime.createNote).toHaveBeenCalledWith(
expect.objectContaining({
worktreeSelector: 'id:wt-1',
title: 'Plan',
bodyMarkdown: 'body',
makeActive: true
})
)
expect(runtime.saveNote).toHaveBeenCalledWith(
expect.objectContaining({
worktreeSelector: 'id:wt-1',
note: 'note-1',
title: 'Plan v2',
bodyMarkdown: 'updated',
revision: 2,
makeActive: true
})
)
expect(runtime.renameNote).toHaveBeenCalledWith(
expect.objectContaining({
worktreeSelector: 'id:wt-1',
note: 'note-1',
title: 'Renamed'
})
)
expect(runtime.deleteNote).toHaveBeenCalledWith({
worktreeSelector: 'id:wt-1',
note: 'note-1'
})
expect(runtime.appendNote).toHaveBeenCalledWith(
expect.objectContaining({
worktreeSelector: 'id:wt-1',
note: 'note-1',
bodyMarkdown: 'more',
makeActive: true
})
)
expect(runtime.linkNote).toHaveBeenCalledWith({
worktreeSelector: 'id:wt-1',
note: 'note-1',
kind: 'active'
})
})
it('routes panel state and search through the selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
searchNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }),
resolveNotesPanelOpenStateForWorktree: vi.fn().mockResolvedValue({ state: 'emptyDraft' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS })
await dispatcher.dispatch(
makeRequest('note.search', { worktree: 'id:wt-1', query: 'todo', limit: 20 })
)
await dispatcher.dispatch(makeRequest('note.panelState', { worktree: 'id:wt-1' }))
expect(runtime.searchNotes).toHaveBeenCalledWith({
worktreeSelector: 'id:wt-1',
query: 'todo',
limit: 20
})
expect(runtime.resolveNotesPanelOpenStateForWorktree).toHaveBeenCalledWith({
worktreeSelector: 'id:wt-1'
})
})
})
+84 -4
View File
@@ -17,13 +17,34 @@ const NoteShowParams = NoteScopedParams.extend({
const NoteCreateParams = NoteScopedParams.extend({
title: requiredString('Missing note title'),
bodyMarkdown: OptionalString,
makeActive: z.boolean().optional()
makeActive: z.boolean().optional(),
createdBySessionId: z.string().nullable().optional()
})
const NoteSaveParams = NoteScopedParams.extend({
note: requiredString('Missing note selector'),
title: OptionalString,
bodyMarkdown: requiredString('Missing note body'),
revision: OptionalFiniteNumber,
makeActive: z.boolean().optional(),
updatedBySessionId: z.string().nullable().optional()
})
const NoteRenameParams = NoteScopedParams.extend({
note: requiredString('Missing note selector'),
title: requiredString('Missing note title'),
updatedBySessionId: z.string().nullable().optional()
})
const NoteDeleteParams = NoteScopedParams.extend({
note: requiredString('Missing note selector')
})
const NoteAppendParams = NoteScopedParams.extend({
note: requiredString('Missing note selector'),
bodyMarkdown: requiredString('Missing note body'),
makeActive: z.boolean().optional()
makeActive: z.boolean().optional(),
updatedBySessionId: z.string().nullable().optional()
})
const NoteSearchParams = NoteScopedParams.extend({
@@ -31,6 +52,11 @@ const NoteSearchParams = NoteScopedParams.extend({
limit: OptionalFiniteNumber
})
const NoteLinkParams = NoteScopedParams.extend({
note: requiredString('Missing note selector'),
kind: z.enum(['active', 'referenced'])
})
export const NOTE_METHODS: readonly RpcAnyMethod[] = [
defineMethod({
name: 'note.list',
@@ -58,7 +84,42 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [
worktreeSelector: params.worktree,
title: params.title,
bodyMarkdown: params.bodyMarkdown,
makeActive: params.makeActive
makeActive: params.makeActive,
createdBySessionId: params.createdBySessionId
})
}),
defineMethod({
name: 'note.save',
params: NoteSaveParams,
handler: async (params, { runtime }) =>
await runtime.saveNote({
worktreeSelector: params.worktree,
note: params.note,
title: params.title,
bodyMarkdown: params.bodyMarkdown,
revision: params.revision,
makeActive: params.makeActive,
updatedBySessionId: params.updatedBySessionId
})
}),
defineMethod({
name: 'note.rename',
params: NoteRenameParams,
handler: async (params, { runtime }) =>
await runtime.renameNote({
worktreeSelector: params.worktree,
note: params.note,
title: params.title,
updatedBySessionId: params.updatedBySessionId
})
}),
defineMethod({
name: 'note.delete',
params: NoteDeleteParams,
handler: async (params, { runtime }) =>
await runtime.deleteNote({
worktreeSelector: params.worktree,
note: params.note
})
}),
defineMethod({
@@ -69,7 +130,8 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [
worktreeSelector: params.worktree,
note: params.note,
bodyMarkdown: params.bodyMarkdown,
makeActive: params.makeActive
makeActive: params.makeActive,
updatedBySessionId: params.updatedBySessionId
})
}),
defineMethod({
@@ -81,5 +143,23 @@ export const NOTE_METHODS: readonly RpcAnyMethod[] = [
query: params.query,
limit: params.limit
})
}),
defineMethod({
name: 'note.link',
params: NoteLinkParams,
handler: async (params, { runtime }) =>
await runtime.linkNote({
worktreeSelector: params.worktree,
note: params.note,
kind: params.kind
})
}),
defineMethod({
name: 'note.panelState',
params: NoteScopedParams,
handler: async (params, { runtime }) =>
await runtime.resolveNotesPanelOpenStateForWorktree({
worktreeSelector: params.worktree
})
})
]
@@ -349,6 +349,42 @@ describe('orchestration RPC methods', () => {
// Must not have marked read
expect(db.getUnreadMessages('b')).toHaveLength(1)
})
it('does not mark messages read when a waiting check is aborted', async () => {
setup()
const abortController = new AbortController()
ctx = { runtime, signal: abortController.signal }
vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => {
db.insertMessage({ from: 'a', to: 'b', subject: 'arrived during close' })
abortController.abort()
})
const result = (await call('orchestration.check', {
terminal: 'b',
wait: true,
timeoutMs: 100
})) as { messages: unknown[]; count: number }
expect(result).toEqual({ messages: [], count: 0 })
expect(db.getUnreadMessages('b')).toHaveLength(1)
})
it('does not mark existing messages read when the check starts aborted', async () => {
setup()
const abortController = new AbortController()
abortController.abort()
ctx = { runtime, signal: abortController.signal }
db.insertMessage({ from: 'a', to: 'b', subject: 'already unread' })
const result = (await call('orchestration.check', {
terminal: 'b',
wait: true,
timeoutMs: 100
})) as { messages: unknown[]; count: number }
expect(result).toEqual({ messages: [], count: 0 })
expect(db.getUnreadMessages('b')).toHaveLength(1)
})
})
describe('orchestration.reply', () => {
@@ -241,6 +241,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
return { messages, count: messages.length }
}
if (signal?.aborted) {
return { messages: [], count: 0 }
}
const result = readAndReturn()
if (result.count > 0 || !params.wait) {
return result
@@ -257,6 +260,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
timeoutMs: params.timeoutMs ?? undefined,
signal
})
if (signal?.aborted) {
return { messages: [], count: 0 }
}
return readAndReturn()
}
}),
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { REPO_METHODS } from './repo'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('repo RPC methods', () => {
it('creates a repo on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
createRepo: vi.fn().mockResolvedValue({
repo: { id: 'repo-1', path: '/srv/projects/new-app', kind: 'git' }
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('repo.create', {
parentPath: '/srv/projects',
name: 'new-app',
kind: 'git'
})
)
expect(runtime.createRepo).toHaveBeenCalledWith('/srv/projects', 'new-app', 'git')
expect(response).toMatchObject({
ok: true,
result: { repo: { id: 'repo-1', path: '/srv/projects/new-app' } }
})
})
it('clones a repo on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
cloneRepo: vi.fn().mockResolvedValue({
id: 'repo-1',
path: '/srv/projects/orca',
kind: 'git'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('repo.clone', {
url: 'https://github.com/example/orca.git',
destination: '/srv/projects'
})
)
expect(runtime.cloneRepo).toHaveBeenCalledWith(
'https://github.com/example/orca.git',
'/srv/projects'
)
expect(response).toMatchObject({
ok: true,
result: { repo: { id: 'repo-1', path: '/srv/projects/orca' } }
})
})
it('routes repository hook operations to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
checkRepoHooks: vi.fn().mockResolvedValue({
hasHooks: true,
hooks: { scripts: { setup: 'pnpm install' } },
mayNeedUpdate: false
}),
readRepoIssueCommand: vi.fn().mockResolvedValue({
localContent: null,
sharedContent: 'Fix {{artifact_url}}',
effectiveContent: 'Fix {{artifact_url}}',
localFilePath: '/srv/repo/.orca/issue-command',
source: 'shared'
}),
writeRepoIssueCommand: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
await dispatcher.dispatch(makeRequest('repo.hooksCheck', { repo: 'repo-1' }))
await dispatcher.dispatch(makeRequest('repo.issueCommandRead', { repo: 'repo-1' }))
await dispatcher.dispatch(
makeRequest('repo.issueCommandWrite', {
repo: 'repo-1',
content: 'Fix it'
})
)
expect(runtime.checkRepoHooks).toHaveBeenCalledWith('repo-1')
expect(runtime.readRepoIssueCommand).toHaveBeenCalledWith('repo-1')
expect(runtime.writeRepoIssueCommand).toHaveBeenCalledWith('repo-1', 'Fix it')
})
})

Some files were not shown because too many files have changed in this diff Show More