fix: improve cross-platform compatibility (#2064)

This commit is contained in:
Jinjing 2026-05-15 21:58:58 -07:00 committed by GitHub
parent 84d26fc747
commit 90e49669e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1041 additions and 141 deletions

View File

@ -91,10 +91,11 @@ class AudioEngine (context: Context) {
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
requestAudioFocus()
// Route audio to external device if connected, otherwise route to speaker
updateAudioRouting()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// Route audio to external device if connected, otherwise route to speaker.
// AudioDeviceInfo/getDevices are API 23+, while the module supports API 21.
updateAudioRouting()
// Listen for changes in audio routing
val callback = object:android.media.AudioDeviceCallback(){
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
@ -110,6 +111,8 @@ class AudioEngine (context: Context) {
}
audioDeviceCallback = callback
audioManager.registerAudioDeviceCallback(callback, null)
} else {
updateLegacyAudioRouting()
}
val bufferSize = AudioTrack.getMinBufferSize(
@ -136,6 +139,7 @@ class AudioEngine (context: Context) {
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun updateAudioRouting() {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
var isExternalDeviceConnected = false
@ -177,6 +181,15 @@ class AudioEngine (context: Context) {
}
}
@Suppress("DEPRECATION")
private fun updateLegacyAudioRouting() {
val isExternalDeviceConnected =
audioManager.isWiredHeadsetOn ||
audioManager.isBluetoothScoOn ||
audioManager.isBluetoothA2dpOn
audioManager.isSpeakerphoneOn = !isExternalDeviceConnected
}
@SuppressLint("NewApi")
private fun requestAudioFocus() {
val listener = AudioManager.OnAudioFocusChangeListener { focusChange ->

View File

@ -710,6 +710,13 @@ app.whenReady().then(async () => {
})
automations = new AutomationService(store, { claudeUsage, codexUsage })
runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
runtime.setCommitMessageAgentEnvironmentResolvers({
prepareForCodexLaunch: () =>
store!.getSettings().activeCodexManagedAccountId
? codexRuntimeHome!.prepareForCodexLaunch()
: null,
prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch()
})
disposeFeatureWallFirstAgentTour = registerFeatureWallFirstAgentTour({
stats,
getWindow: () => mainWindow

View File

@ -66,8 +66,10 @@ import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './mar
import { checkRgAvailable } from './rg-availability'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
import {
prepareLocalCommitMessageAgentEnv,
type CommitMessageAgentEnvironmentResolvers
} from '../text-generation/commit-message-agent-environment'
// Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes
// ordinary JSON/log files inaccessible before the editor can degrade features.
@ -93,56 +95,6 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
'.pdf': 'application/pdf'
}
export type CommitMessageAgentEnvironmentResolvers = {
prepareForCodexLaunch?: () => string | null
prepareForClaudeLaunch?: () => Promise<ClaudeRuntimeAuthPreparation>
}
function cloneProcessEnv(): Record<string, string> {
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
env[key] = value
}
}
return env
}
async function prepareLocalCommitMessageAgentEnv(
agentId: string,
resolvers: CommitMessageAgentEnvironmentResolvers | undefined
): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> {
if (!resolvers) {
return { ok: true }
}
try {
if (agentId === 'codex' && resolvers.prepareForCodexLaunch) {
const codexHomePath = resolvers.prepareForCodexLaunch()
return {
ok: true,
env: codexHomePath ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } : undefined
}
}
if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) {
const preparation = await resolvers.prepareForClaudeLaunch()
const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch, {
stripAuthEnv: preparation.stripAuthEnv
})
return { ok: true, env }
}
} catch (error) {
console.error('[filesystem] Failed to prepare commit message agent environment:', error)
return {
ok: false,
error: 'Failed to prepare the selected agent account for commit message generation.'
}
}
return { ok: true }
}
/**
* Check if a buffer appears to be binary (contains null bytes in first 8KB).
*/

View File

@ -4,10 +4,8 @@ import { registerPreflightHandlers } from './preflight'
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { StatsCollector } from '../stats/collector'
import {
registerFilesystemHandlers,
type CommitMessageAgentEnvironmentResolvers
} from './filesystem'
import { registerFilesystemHandlers } from './filesystem'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher'
import { registerClaudeUsageHandlers } from './claude-usage'
import { registerCodexUsageHandlers } from './codex-usage'

View File

@ -0,0 +1,122 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/types'
import type * as GitStatusModule from '../git/status'
import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation'
import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git'
const mocks = vi.hoisted(() => ({
getStagedCommitContext: vi.fn(),
generateCommitMessageFromContext: vi.fn(),
resolveCommitMessageSettings: vi.fn()
}))
vi.mock('../git/status', async () => ({
...(await vi.importActual<typeof GitStatusModule>('../git/status')),
getStagedCommitContext: mocks.getStagedCommitContext
}))
vi.mock('../text-generation/commit-message-text-generation', async () => ({
...(await vi.importActual<typeof CommitMessageTextGenerationModule>(
'../text-generation/commit-message-text-generation'
)),
generateCommitMessageFromContext: mocks.generateCommitMessageFromContext,
resolveCommitMessageSettings: mocks.resolveCommitMessageSettings
}))
const tempDirs: string[] = []
function makeWorktree(path: string): ResolvedRuntimeGitWorktree {
return {
id: 'wt-1',
repoId: 'repo-1',
path,
git: {
path,
branch: 'main',
bare: false,
detached: false,
head: 'a'.repeat(40)
}
} as unknown as ResolvedRuntimeGitWorktree
}
function makeCommands(worktreePath: string): RuntimeGitCommands {
return new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
getRuntimeSettings: () => ({}) as GlobalSettings
})
}
describe('RuntimeGitCommands', () => {
beforeEach(() => {
mocks.getStagedCommitContext.mockReset()
mocks.generateCommitMessageFromContext.mockReset()
mocks.resolveCommitMessageSettings.mockReset()
})
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop()!, { recursive: true, force: true })
}
})
it('rejects slash-only git mutation paths before they can target the worktree root', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
const commands = makeCommands(worktreePath)
await expect(commands.bulkDiscardRuntimeGitPaths('id:wt-1', ['///'])).rejects.toThrow(
'invalid_relative_path'
)
await expect(commands.discardRuntimeGitPath('id:wt-1', '///')).rejects.toThrow(
'invalid_relative_path'
)
})
it('prepares the selected local agent environment before generating commit messages', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
const context = {
branch: 'main',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
mocks.getStagedCommitContext.mockResolvedValue(context)
mocks.generateCommitMessageFromContext.mockResolvedValue({
success: true,
message: 'docs: update readme'
})
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
getRuntimeSettings: () =>
({
commitMessageAi: { enabled: true, agentId: 'codex' },
agentCmdOverrides: {},
enableGitHubAttribution: false
}) as GlobalSettings,
getCommitMessageAgentEnvironment: () => ({
prepareForCodexLaunch: () => '/managed/codex-home'
})
})
await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({
success: true,
message: 'docs: update readme'
})
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
context,
params,
expect.objectContaining({
kind: 'local',
cwd: worktreePath,
env: expect.objectContaining({ CODEX_HOME: '/managed/codex-home' })
})
)
})
})

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: runtime git dispatch stays in one boundary so local, SSH, and runtime-environment behavior remains comparable. */
import type {
GitBranchCompareResult,
GitConflictOperation,
@ -6,10 +7,13 @@ import type {
GitStatusResult,
GitUpstreamStatus,
GitWorktreeInfo,
GlobalSettings,
Worktree
} from '../../shared/types'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import { getRemoteFileUrl } from '../git/repo'
import {
bulkDiscardChanges,
bulkStageFiles,
bulkUnstageFiles,
commitChanges,
@ -18,6 +22,7 @@ import {
getBranchCompare,
getBranchDiff,
getDiff,
getStagedCommitContext,
getStatus as getGitStatus,
stageFile,
unstageFile
@ -25,14 +30,37 @@ import {
import { getUpstreamStatus } from '../git/upstream'
import { gitFetch, gitPull, gitPush } from '../git/remote'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import {
cancelGenerateCommitMessageLocal,
generateCommitMessageFromContext,
resolveCommitMessageSettings,
type GenerateCommitMessageResult
} from '../text-generation/commit-message-text-generation'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment'
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
type RuntimeCommitMessageSettingsOverride = Partial<
Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>
>
function normalizeRuntimeGitRelativePath(filePath: string): string {
const relativePath = normalizeRuntimeRelativePath(filePath)
if (relativePath === '') {
// Why: git mutation APIs treat an empty pathspec as the worktree root;
// runtime RPC must never let malformed file paths discard whole worktrees.
throw new Error('invalid_relative_path')
}
return relativePath
}
export type RuntimeGitCommandHost = {
resolveRuntimeGitTarget(
selector: string
): Promise<{ worktree: ResolvedRuntimeGitWorktree; connectionId?: string }>
getRuntimeSettings(): GlobalSettings
getCommitMessageAgentEnvironment?(): CommitMessageAgentEnvironmentResolvers | undefined
}
export class RuntimeGitCommands {
@ -52,7 +80,9 @@ export class RuntimeGitCommands {
? provider.getStatus(target.worktree.path, options)
: provider.getStatus(target.worktree.path)
}
return options ? getGitStatus(target.worktree.path, options) : getGitStatus(target.worktree.path)
return options
? getGitStatus(target.worktree.path, options)
: getGitStatus(target.worktree.path)
}
async getRuntimeGitConflictOperation(worktreeSelector: string): Promise<GitConflictOperation> {
@ -74,7 +104,7 @@ export class RuntimeGitCommands {
compareAgainstHead?: boolean
): Promise<GitDiffResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeRelativePath(filePath)
const relativePath = normalizeRuntimeGitRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -165,8 +195,8 @@ export class RuntimeGitCommands {
oldPath?: string
): Promise<GitDiffResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeRelativePath(filePath)
const oldRelativePath = oldPath ? normalizeRuntimeRelativePath(oldPath) : undefined
const relativePath = normalizeRuntimeGitRelativePath(filePath)
const oldRelativePath = oldPath ? normalizeRuntimeGitRelativePath(oldPath) : undefined
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -213,9 +243,83 @@ export class RuntimeGitCommands {
return commitChanges(target.worktree.path, message)
}
async generateRuntimeCommitMessage(
worktreeSelector: string,
settingsOverride?: RuntimeCommitMessageSettingsOverride
): Promise<GenerateCommitMessageResult> {
const resolvedSettings = resolveCommitMessageSettings({
...this.host.getRuntimeSettings(),
...settingsOverride
})
if (!resolvedSettings.ok) {
return { success: false, error: resolvedSettings.error }
}
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
return {
success: false,
error: `No git provider for connection "${target.connectionId}"`
}
}
let context: CommitMessageDraftContext | null
try {
context = await provider.getStagedCommitContext(target.worktree.path)
} catch (error) {
console.error('[runtime-git] Failed to read remote staged commit context:', error)
return { success: false, error: 'Failed to read staged changes.' }
}
if (!context) {
return { success: false, error: 'No staged changes to summarize.' }
}
return generateCommitMessageFromContext(context, resolvedSettings.params, {
kind: 'remote',
cwd: target.worktree.path,
execute: (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
missingBinaryLocation: 'remote PATH'
})
}
let context: CommitMessageDraftContext | null
try {
context = await getStagedCommitContext(target.worktree.path)
} catch (error) {
console.error('[runtime-git] Failed to read staged commit context:', error)
return { success: false, error: 'Failed to read staged changes.' }
}
if (!context) {
return { success: false, error: 'No staged changes to summarize.' }
}
const localEnv = await prepareLocalCommitMessageAgentEnv(
resolvedSettings.params.agentId,
this.host.getCommitMessageAgentEnvironment?.()
)
if (!localEnv.ok) {
return { success: false, error: localEnv.error }
}
return generateCommitMessageFromContext(context, resolvedSettings.params, {
kind: 'local',
cwd: target.worktree.path,
...(localEnv.env ? { env: localEnv.env } : {})
})
}
async cancelRuntimeGenerateCommitMessage(worktreeSelector: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
await provider?.cancelGenerateCommitMessage(target.worktree.path)
return { ok: true }
}
cancelGenerateCommitMessageLocal(target.worktree.path)
return { ok: true }
}
async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeRelativePath(filePath)
const relativePath = normalizeRuntimeGitRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -230,7 +334,7 @@ export class RuntimeGitCommands {
async unstageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeRelativePath(filePath)
const relativePath = normalizeRuntimeGitRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -248,7 +352,7 @@ export class RuntimeGitCommands {
filePaths: string[]
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path))
const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path))
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -266,7 +370,7 @@ export class RuntimeGitCommands {
filePaths: string[]
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path))
const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path))
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -279,9 +383,27 @@ export class RuntimeGitCommands {
return { ok: true }
}
async bulkDiscardRuntimeGitPaths(
worktreeSelector: string,
filePaths: string[]
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path))
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error('remote_git_unavailable')
}
await provider.bulkDiscardChanges(target.worktree.path, relativePaths)
return { ok: true }
}
await bulkDiscardChanges(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 relativePath = normalizeRuntimeGitRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -300,7 +422,7 @@ export class RuntimeGitCommands {
line: number
): Promise<string | null> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath)
const normalizedRelativePath = normalizeRuntimeGitRelativePath(relativePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {

View File

@ -256,6 +256,7 @@ import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '
import type { RateLimitState } from '../../shared/rate-limit-types'
import type { VoiceSettings } from '../../shared/speech-types'
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
type RuntimeAccountServices = {
claudeAccounts: ClaudeAccountService
@ -848,6 +849,7 @@ export class OrcaRuntimeService {
private optimisticReconcileTokens = new Map<string, string>()
private readonly getLocalProviderFn: (() => IPtyProvider) | null
private accountServices: RuntimeAccountServices | null = null
private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null
private mobileDictation: {
id: string
owner: string
@ -1191,7 +1193,9 @@ export class OrcaRuntimeService {
)
private readonly gitCommands = new RuntimeGitCommands({
resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector)
resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector),
getRuntimeSettings: () => this.requireStore().getSettings() as GlobalSettings,
getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined
})
getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] =
@ -1218,6 +1222,10 @@ export class OrcaRuntimeService {
commitRuntimeGit: RuntimeGitCommands['commitRuntimeGit'] = this.gitCommands.commitRuntimeGit.bind(
this.gitCommands
)
generateRuntimeCommitMessage: RuntimeGitCommands['generateRuntimeCommitMessage'] =
this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands)
cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] =
this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands)
stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] =
this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands)
unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] =
@ -1226,6 +1234,8 @@ export class OrcaRuntimeService {
this.gitCommands.bulkStageRuntimeGitPaths.bind(this.gitCommands)
bulkUnstageRuntimeGitPaths: RuntimeGitCommands['bulkUnstageRuntimeGitPaths'] =
this.gitCommands.bulkUnstageRuntimeGitPaths.bind(this.gitCommands)
bulkDiscardRuntimeGitPaths: RuntimeGitCommands['bulkDiscardRuntimeGitPaths'] =
this.gitCommands.bulkDiscardRuntimeGitPaths.bind(this.gitCommands)
discardRuntimeGitPath: RuntimeGitCommands['discardRuntimeGitPath'] =
this.gitCommands.discardRuntimeGitPath.bind(this.gitCommands)
getRuntimeGitRemoteFileUrl: RuntimeGitCommands['getRuntimeGitRemoteFileUrl'] =
@ -1817,6 +1827,12 @@ export class OrcaRuntimeService {
this.accountServices = services
}
setCommitMessageAgentEnvironmentResolvers(
resolvers: CommitMessageAgentEnvironmentResolvers
): void {
this.commitMessageAgentEnv = resolvers
}
async startMobileDictation(params: {
dictationId: string
modelId?: string

View File

@ -88,7 +88,8 @@ describe('git RPC methods', () => {
getRuntimeId: () => 'test-runtime',
stageRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }),
bulkUnstageRuntimeGitPaths: vi.fn().mockResolvedValue({ ok: true }),
discardRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true })
discardRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }),
bulkDiscardRuntimeGitPaths: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
@ -101,16 +102,42 @@ describe('git RPC methods', () => {
await dispatcher.dispatch(
makeRequest('git.discard', { worktree: 'id:wt-1', filePath: 'src/a.ts' })
)
await dispatcher.dispatch(
makeRequest('git.bulkDiscard', { worktree: 'id:wt-1', filePaths: ['src/a.ts', 'b.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')
expect(runtime.bulkDiscardRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['src/a.ts', 'b.ts'])
})
it('rejects empty bulk mutation paths before calling the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
bulkDiscardRuntimeGitPaths: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.bulkDiscard', { worktree: 'id:wt-1', filePaths: [''] })
)
expect(response.ok).toBe(false)
expect(response).toMatchObject({
error: expect.objectContaining({ code: 'invalid_argument' })
})
expect(runtime.bulkDiscardRuntimeGitPaths).not.toHaveBeenCalled()
})
it('routes remote operations to the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
commitRuntimeGit: vi.fn().mockResolvedValue({ success: true }),
generateRuntimeCommitMessage: vi
.fn()
.mockResolvedValue({ success: true, message: 'feat: test' }),
cancelRuntimeGenerateCommitMessage: vi.fn().mockResolvedValue({ ok: true }),
pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }),
getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3')
} as unknown as OrcaRuntimeService
@ -119,6 +146,10 @@ describe('git RPC methods', () => {
await dispatcher.dispatch(
makeRequest('git.commit', { worktree: 'id:wt-1', message: 'feat: test' })
)
await dispatcher.dispatch(makeRequest('git.generateCommitMessage', { worktree: 'id:wt-1' }))
await dispatcher.dispatch(
makeRequest('git.cancelGenerateCommitMessage', { worktree: 'id:wt-1' })
)
await dispatcher.dispatch(
makeRequest('git.push', {
worktree: 'id:wt-1',
@ -135,10 +166,68 @@ describe('git RPC methods', () => {
)
expect(runtime.commitRuntimeGit).toHaveBeenCalledWith('id:wt-1', 'feat: test')
expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1')
expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1')
expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, { remote: 'origin' })
expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' })
})
it('forwards commit-message settings to the runtime', async () => {
const commitMessageAi = {
enabled: true,
agentId: 'codex',
selectedModelByAgent: { codex: 'gpt-5.3-codex-spark' },
selectedThinkingByModel: { 'gpt-5.3-codex-spark': 'medium' },
customPrompt: '',
customAgentCommand: ''
}
const agentCmdOverrides = { codex: 'codex --profile work' }
const runtime = {
getRuntimeId: () => 'test-runtime',
generateRuntimeCommitMessage: vi.fn().mockResolvedValue({ success: true, message: 'test' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
await dispatcher.dispatch(
makeRequest('git.generateCommitMessage', {
worktree: 'id:wt-1',
commitMessageAi,
agentCmdOverrides,
enableGitHubAttribution: true
})
)
expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1', {
commitMessageAi,
agentCmdOverrides,
enableGitHubAttribution: true
})
})
it('rejects malformed commit-message settings before calling the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
generateRuntimeCommitMessage: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('git.generateCommitMessage', {
worktree: 'id:wt-1',
commitMessageAi: {
enabled: true,
agentId: 'codex'
}
})
)
expect(response.ok).toBe(false)
expect(response).toMatchObject({
error: expect.objectContaining({ code: 'invalid_argument' })
})
expect(runtime.generateRuntimeCommitMessage).not.toHaveBeenCalled()
})
it('rejects branch diff revisions that are not full object ids', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',

View File

@ -1,5 +1,6 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import type { GlobalSettings } from '../../../../shared/types'
const WorktreeSelector = z.object({
worktree: z
@ -57,8 +58,23 @@ const GitCommit = WorktreeSelector.extend({
.pipe(z.string().min(1, 'Missing commit message'))
})
const CommitMessageAiSettings = z.object({
enabled: z.boolean(),
agentId: z.string().nullable(),
selectedModelByAgent: z.record(z.string(), z.string()),
selectedThinkingByModel: z.record(z.string(), z.string()),
customPrompt: z.string(),
customAgentCommand: z.string()
})
const GitGenerateCommitMessage = WorktreeSelector.extend({
commitMessageAi: CommitMessageAiSettings.optional(),
agentCmdOverrides: z.record(z.string(), z.string()).optional(),
enableGitHubAttribution: z.boolean().optional()
})
const GitBulkPaths = WorktreeSelector.extend({
filePaths: z.array(z.string())
filePaths: z.array(z.string().min(1, 'Missing file path'))
})
const GitPush = WorktreeSelector.extend({
@ -143,6 +159,38 @@ export const GIT_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.commitRuntimeGit(params.worktree, params.message)
}),
defineMethod({
name: 'git.generateCommitMessage',
params: GitGenerateCommitMessage,
handler: async (params, { runtime }) => {
if (
params.commitMessageAi === undefined &&
params.agentCmdOverrides === undefined &&
params.enableGitHubAttribution === undefined
) {
return runtime.generateRuntimeCommitMessage(params.worktree)
}
return runtime.generateRuntimeCommitMessage(params.worktree, {
...(params.commitMessageAi !== undefined
? { commitMessageAi: params.commitMessageAi as GlobalSettings['commitMessageAi'] }
: {}),
...(params.agentCmdOverrides !== undefined
? {
agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides']
}
: {}),
...(params.enableGitHubAttribution !== undefined
? { enableGitHubAttribution: params.enableGitHubAttribution }
: {})
})
}
}),
defineMethod({
name: 'git.cancelGenerateCommitMessage',
params: WorktreeSelector,
handler: async (params, { runtime }) =>
runtime.cancelRuntimeGenerateCommitMessage(params.worktree)
}),
defineMethod({
name: 'git.stage',
params: GitFilePath,
@ -173,6 +221,12 @@ export const GIT_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.discardRuntimeGitPath(params.worktree, params.filePath)
}),
defineMethod({
name: 'git.bulkDiscard',
params: GitBulkPaths,
handler: async (params, { runtime }) =>
runtime.bulkDiscardRuntimeGitPaths(params.worktree, params.filePaths)
}),
defineMethod({
name: 'git.remoteFileUrl',
params: GitRemoteFileUrl,

View File

@ -694,6 +694,40 @@ describe('OrcaRuntimeRpcServer', () => {
expect(pushRuntimeGit).not.toHaveBeenCalled()
})
it('rejects WebSocket requests whose request token differs from the authenticated channel token', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = {
getRuntimeId: () => 'test-runtime',
getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' })
} as unknown as OrcaRuntimeService
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
const channelDevice = server['deviceRegistry']!.addDevice('phone', 'mobile')
const requestDevice = server['deviceRegistry']!.addDevice('cli', 'runtime')
const replies: Record<string, unknown>[] = []
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_mismatch',
method: 'status.get',
deviceToken: requestDevice.token
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {},
undefined,
undefined,
channelDevice.token
)
expect(replies).toContainEqual(
expect.objectContaining({
id: 'req_mismatch',
ok: false,
error: expect.objectContaining({ code: 'unauthorized' })
})
)
})
it('allows runtime-scoped WebSocket tokens to use the full RPC surface', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true })

View File

@ -459,12 +459,14 @@ export class OrcaRuntimeRpcServer {
}
})
channel.onMessage((plaintext, encryptedReply, encryptedBinaryReply) => {
const authenticatedDeviceToken = this.e2eeChannels.get(ws)?.deviceToken ?? null
void this.handleWebSocketMessage(
plaintext,
encryptedReply,
encryptedBinaryReply,
wsTransport,
ws
ws,
authenticatedDeviceToken
)
})
channel.onBinaryMessage((bytes) => this.handleWebSocketBinaryMessage(bytes, ws))
@ -632,7 +634,8 @@ export class OrcaRuntimeRpcServer {
reply: (response: string) => void,
sendBinary: (response: Uint8Array<ArrayBufferLike>) => void,
wsTransport?: WebSocketTransport,
ws?: WebSocket
ws?: WebSocket,
authenticatedDeviceToken?: string | null
): Promise<void> {
let request: RpcRequest
try {
@ -651,10 +654,17 @@ export class OrcaRuntimeRpcServer {
return
}
const token =
const requestToken =
typeof (request as Record<string, unknown>).deviceToken === 'string'
? ((request as Record<string, unknown>).deviceToken as string)
: null
if (authenticatedDeviceToken && requestToken && requestToken !== authenticatedDeviceToken) {
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Device token mismatch')))
return
}
// Why: E2EE already authenticated the WebSocket channel. Use that bound
// identity for authorization instead of trusting a repeated request field.
const token = authenticatedDeviceToken ?? requestToken
if (!token) {
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token')))
return

View File

@ -11,6 +11,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
sizeBytes: 180_000_000,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2',
archiveSha256: '5793d0fd397c5778d2cf2126994d58e9d56b1be7c04d13c7a15bb1b4eafb16bf',
archiveFormat: 'tar.bz2',
files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'joiner.int8.onnx', 'tokens.txt'],
sampleRate: 16000,
@ -28,6 +29,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
sizeBytes: 170_000_000,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2',
archiveSha256: '157c157bc51155e03e37d2466522a3a737dd9c72bb25f36eb18912964161e1ad',
archiveFormat: 'tar.bz2',
files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'joiner.int8.onnx', 'tokens.txt'],
sampleRate: 16000,
@ -43,6 +45,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
sizeBytes: 130_000_000,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20.tar.bz2',
archiveSha256: '27ffbd9ee24ad186d99acc2f6354d7992b27bcab490812510665fa8f9389c5f8',
archiveFormat: 'tar.bz2',
files: [
'encoder-epoch-99-avg-1.onnx',
@ -64,6 +67,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
sizeBytes: 115_000_000,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-paraformer-bilingual-zh-en.tar.bz2',
archiveSha256: '5462a1fce42693deae572af1e8c4687124b12aa85fe61ff4d3168bb5280e205f',
archiveFormat: 'tar.bz2',
files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'tokens.txt'],
sampleRate: 16000,
@ -78,6 +82,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
sizeBytes: 128_000_000,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17.tar.bz2',
archiveSha256: '9c559283e8498d3fe95913c79ca1cb454bb26281ac2b102b41306c7d752765d9',
archiveFormat: 'tar.bz2',
files: [
'encoder-epoch-99-avg-1.onnx',
@ -98,6 +103,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
sizeBytes: 74_000_000,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23.tar.bz2',
archiveSha256: '2cbd71b640d9c37d3784f29367333a4577b0398b62e9deeed418170b081cba8b',
archiveFormat: 'tar.bz2',
files: [
'encoder-epoch-99-avg-1.onnx',
@ -118,6 +124,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
sizeBytes: 116_000_000,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-tiny.tar.bz2',
archiveSha256: 'c46116994e539aa165266d96b325252728429c12535eb9d8b6a2b10f129e66b1',
archiveFormat: 'tar.bz2',
files: ['tiny-encoder.onnx', 'tiny-decoder.onnx', 'tiny-tokens.txt'],
sampleRate: 16000,

View File

@ -0,0 +1,68 @@
import { createHash } from 'crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it, vi } from 'vitest'
import { SPEECH_MODEL_CATALOG } from './model-catalog'
import { ModelManager } from './model-manager'
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-speech-models-test'
}
}))
type ModelManagerInternals = {
verifyArchiveSha256: (archivePath: string, expectedSha256: string) => Promise<void>
downloadFile: (
url: string,
dest: string,
expectedSize: number,
modelId: string,
isAborted: () => boolean
) => Promise<void>
}
describe('ModelManager', () => {
it('requires pinned SHA-256 hashes for every catalog archive', () => {
for (const manifest of SPEECH_MODEL_CATALOG) {
expect(manifest.archiveSha256).toMatch(/^[a-f0-9]{64}$/)
}
})
it('verifies downloaded archive hashes before extraction', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
try {
const archivePath = join(dir, 'model.tar.bz2')
writeFileSync(archivePath, 'known archive bytes')
const expected = createHash('sha256').update('known archive bytes').digest('hex')
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
await expect(manager.verifyArchiveSha256(archivePath, expected)).resolves.toBeUndefined()
await expect(manager.verifyArchiveSha256(archivePath, '0'.repeat(64))).rejects.toThrow(
/integrity verification/
)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('rejects non-HTTPS model downloads', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
try {
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
await expect(
manager.downloadFile(
'http://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
1,
'm',
() => false
)
).rejects.toThrow(/HTTPS/)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})

View File

@ -1,9 +1,11 @@
/* eslint-disable max-lines -- Why: model download, checksum, extraction, and cleanup share one state machine so progress/error transitions stay coupled. */
import { app } from 'electron'
import { join, resolve, relative } from 'path'
import { existsSync, mkdirSync, createWriteStream, rmSync } from 'fs'
import { existsSync, mkdirSync, createWriteStream, createReadStream, rmSync } from 'fs'
import { readdir, rm } from 'fs/promises'
import { createHash } from 'crypto'
import { get as httpsGet } from 'https'
import { get as httpGet, type IncomingMessage } from 'http'
import type { IncomingMessage } from 'http'
import { pipeline } from 'stream/promises'
import { spawn } from 'child_process'
import type {
@ -12,6 +14,7 @@ import type {
SpeechModelStatus
} from '../../shared/speech-types'
import { SPEECH_MODEL_CATALOG, getCatalogModel } from './model-catalog'
import { resolveTarExecutable } from './tar-executable'
type DownloadHandle = {
abort: () => void
@ -132,6 +135,13 @@ export class ModelManager {
return
}
await this.verifyArchiveSha256(archivePath, manifest.archiveSha256)
if (aborted) {
this.cleanup(modelId, archivePath)
return
}
this.updateState(modelId, 'extracting')
await this.extractArchive(archivePath, this.modelsDir, modelId, () => aborted)
@ -212,25 +222,71 @@ export class ModelManager {
dest: string,
expectedSize: number,
modelId: string,
isAborted: () => boolean
isAborted: () => boolean,
redirectCount = 0
): Promise<void> {
return new Promise((resolve, reject) => {
const getter = url.startsWith('https') ? httpsGet : httpGet
let parsedUrl: URL
try {
parsedUrl = new URL(url)
} catch {
reject(new Error('Invalid download URL'))
return
}
const request = getter(url, (response: IncomingMessage) => {
if (response.statusCode === 301 || response.statusCode === 302) {
if (parsedUrl.protocol !== 'https:') {
reject(new Error('Model downloads must use HTTPS'))
return
}
const request = httpsGet(parsedUrl, (response: IncomingMessage) => {
if (
response.statusCode === 301 ||
response.statusCode === 302 ||
response.statusCode === 303 ||
response.statusCode === 307 ||
response.statusCode === 308
) {
const redirectUrl = response.headers.location
if (!redirectUrl) {
response.resume()
reject(new Error('Redirect without location'))
return
}
this.downloadFile(redirectUrl, dest, expectedSize, modelId, isAborted)
if (redirectCount >= 5) {
response.resume()
reject(new Error('Too many redirects'))
return
}
let resolvedRedirect: URL
try {
resolvedRedirect = new URL(redirectUrl, parsedUrl)
} catch {
response.resume()
reject(new Error('Invalid redirect URL'))
return
}
if (resolvedRedirect.protocol !== 'https:') {
response.resume()
reject(new Error('Model download redirect must use HTTPS'))
return
}
response.resume()
this.downloadFile(
resolvedRedirect.toString(),
dest,
expectedSize,
modelId,
isAborted,
redirectCount + 1
)
.then(resolve)
.catch(reject)
return
}
if (response.statusCode !== 200) {
response.resume()
reject(new Error(`HTTP ${response.statusCode}`))
return
}
@ -266,6 +322,26 @@ export class ModelManager {
})
}
private verifyArchiveSha256(archivePath: string, expectedSha256: string): Promise<void> {
return new Promise((resolve, reject) => {
const hash = createHash('sha256')
const stream = createReadStream(archivePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.on('error', reject)
stream.on('end', () => {
const actualSha256 = hash.digest('hex')
if (actualSha256 !== expectedSha256.toLowerCase()) {
// Why: these archives feed native model parsers; filename checks do
// not protect against compromised or redirected release assets.
reject(new Error('Downloaded model archive failed integrity verification'))
return
}
resolve()
})
})
}
private extractArchive(
archivePath: string,
destDir: string,
@ -280,9 +356,15 @@ export class ModelManager {
// (1MB default maxBuffer). bzip2 decompression is slow (~1-5 min for
// 170MB archives) and exec can silently kill the process if stderr
// exceeds the buffer. spawn streams output without buffering.
const child = spawn('tar', ['-xjf', archivePath, '-C', modelDir, '--strip-components=1'], {
stdio: ['ignore', 'ignore', 'pipe']
})
const tarExecutable = resolveTarExecutable()
const child = spawn(
tarExecutable,
['-xjf', archivePath, '-C', modelDir, '--strip-components=1'],
{
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true
}
)
let stderr = ''
child.stderr?.on('data', (chunk: Buffer) => {

View File

@ -0,0 +1,27 @@
import { existsSync } from 'fs'
import { win32 as pathWin32 } from 'path'
export function resolveTarExecutable(
options: {
platform?: NodeJS.Platform
env?: NodeJS.ProcessEnv
exists?: (path: string) => boolean
} = {}
): string {
const platform = options.platform ?? process.platform
if (platform !== 'win32') {
return 'tar'
}
const env = options.env ?? process.env
const systemRoot = env.SystemRoot ?? env.WINDIR ?? 'C:\\Windows'
const candidate = pathWin32.join(systemRoot, 'System32', 'tar.exe')
const exists = options.exists ?? existsSync
if (exists(candidate)) {
return candidate
}
// Why: packaged Windows apps can have a stripped PATH. Use the OS tar
// location explicitly, and fail with a repairable error if it is absent.
throw new Error(`Windows tar.exe not found at ${candidate}`)
}

View File

@ -0,0 +1,52 @@
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
export type CommitMessageAgentEnvironmentResolvers = {
prepareForCodexLaunch?: () => string | null
prepareForClaudeLaunch?: () => Promise<ClaudeRuntimeAuthPreparation>
}
function cloneProcessEnv(): Record<string, string> {
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
env[key] = value
}
}
return env
}
export async function prepareLocalCommitMessageAgentEnv(
agentId: string,
resolvers: CommitMessageAgentEnvironmentResolvers | undefined
): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> {
if (!resolvers) {
return { ok: true }
}
try {
if (agentId === 'codex' && resolvers.prepareForCodexLaunch) {
const codexHomePath = resolvers.prepareForCodexLaunch()
return {
ok: true,
env: codexHomePath ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } : undefined
}
}
if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) {
const preparation = await resolvers.prepareForClaudeLaunch()
const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch, {
stripAuthEnv: preparation.stripAuthEnv
})
return { ok: true, env }
}
} catch (error) {
console.error('[commit-message] Failed to prepare agent environment:', error)
return {
ok: false,
error: 'Failed to prepare the selected agent account for commit message generation.'
}
}
return { ok: true }
}

View File

@ -85,14 +85,23 @@ export function createRichMarkdownExtensions({
| RuntimeFileOperationArgs
| undefined
if (src && fp) {
// Why: when IPC resolution fails (e.g. unsupported format),
// the ternary falls back to the raw src so the browser can
// attempt its own loading rather than leaving a broken image.
void loadLocalImageSrc(src, fp, undefined, runtimeContext).then((resolved) => {
img.src = resolved ? resolved : src
if (currentSrc !== src) {
return
}
if (resolved) {
img.src = resolved
return
}
// Why: local image paths must stay behind IPC/runtime
// authorization; a failed load should render missing, not
// hand the raw path back to Chromium.
img.removeAttribute('src')
})
} else if (src) {
img.src = src
} else {
img.removeAttribute('src')
}
}

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { getLocalImageCacheKey } from './useLocalImageSrc'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getLocalImageCacheKey, loadLocalImageSrc } from './useLocalImageSrc'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('getLocalImageCacheKey', () => {
it('scopes local markdown image cache entries by runtime owner', () => {
@ -22,4 +26,35 @@ describe('getLocalImageCacheKey', () => {
expect(localKey).not.toBe(remoteKey)
expect(remoteKey).not.toBe(otherRemoteKey)
})
it('does not fall back to raw local src when IPC returns non-binary content', async () => {
const readFile = vi.fn().mockResolvedValue({
isBinary: false,
content: '<svg></svg>',
mimeType: 'image/svg+xml'
})
vi.stubGlobal('window', {
api: {
fs: { readFile }
}
})
await expect(loadLocalImageSrc('diagram.svg', '/repo/docs/readme.md')).resolves.toBeNull()
expect(readFile).toHaveBeenCalledWith({
filePath: '/repo/docs/diagram.svg',
connectionId: undefined
})
})
it('does not fall back to raw local src when IPC rejects the read', async () => {
vi.stubGlobal('window', {
api: {
fs: { readFile: vi.fn().mockRejectedValue(new Error('denied')) }
}
})
await expect(
loadLocalImageSrc('file:///repo/docs/diagram.png', '/repo/docs/readme.md')
).resolves.toBeNull()
})
})

View File

@ -153,7 +153,7 @@ export function useLocalImageSrc(
const absolutePath = resolveImageAbsolutePath(rawSrc, filePath)
if (!absolutePath) {
setDisplaySrc(rawSrc)
setDisplaySrc(undefined)
return
}
@ -174,15 +174,14 @@ export function useLocalImageSrc(
cacheBlobUrl(cacheKey, url)
setDisplaySrc(url)
} else {
// Why: if the file exists but is not binary (e.g. an SVG stored as
// text) or content is empty, fall back to the raw src so the browser
// can attempt its own loading rather than leaving a broken image.
setDisplaySrc(rawSrc)
// Why: local image paths must stay behind IPC/runtime authorization;
// handing raw file: or relative paths back to Chromium can escape it.
setDisplaySrc(undefined)
}
})
.catch(() => {
if (!cancelled) {
setDisplaySrc(rawSrc)
setDisplaySrc(undefined)
}
})
@ -232,10 +231,9 @@ export async function loadLocalImageSrc(
cacheBlobUrl(cacheKey, url)
return url
}
// Why: if the file is not binary (e.g. an SVG stored as text) or content
// is empty, return the raw src so the caller can still display something
// rather than treating it as a permanent failure.
return rawSrc
// Why: local image paths must stay behind IPC/runtime authorization;
// callers should render a missing image instead of falling back to raw src.
return null
} catch {
// Fall through
}

View File

@ -108,10 +108,13 @@ import {
} from '@/components/editor/editor-autosave'
import { getConnectionId } from '@/lib/connection-context'
import {
bulkDiscardRuntimeGitPaths,
bulkStageRuntimeGitPaths,
bulkUnstageRuntimeGitPaths,
cancelRuntimeGenerateCommitMessage,
commitRuntimeGit,
discardRuntimeGitPath,
generateRuntimeCommitMessage,
getRuntimeGitBranchCompare,
stageRuntimeGitPath,
unstageRuntimeGitPath
@ -1027,12 +1030,12 @@ function SourceControlInner(): React.JSX.Element {
setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
try {
const result = (await window.api.git.generateCommitMessage({
const result = await generateRuntimeCommitMessage({
settings: useAppStore.getState().settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
})) as
| { success: true; message: string; agentLabel?: string }
| { success: false; error: string; canceled?: boolean }
})
if (!result.success) {
// Why: cancellation is a deliberate user action, not a failure to
@ -1082,7 +1085,12 @@ function SourceControlInner(): React.JSX.Element {
// Why: fire-and-forget — the in-flight generateCommitMessage promise
// resolves with `{canceled: true}` once the kill propagates, which is
// where the spinner is cleared. Awaiting here would just delay UI feedback.
void window.api.git.cancelGenerateCommitMessage({ worktreePath, connectionId })
void cancelRuntimeGenerateCommitMessage({
settings: useAppStore.getState().settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
})
}, [activeWorktreeId, worktreePath])
// Why: a single dispatcher for every remote-only action the split button or
@ -2071,7 +2079,15 @@ function SourceControlInner(): React.JSX.Element {
)
)
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
await window.api.git.bulkDiscard({ worktreePath, filePaths, connectionId })
await bulkDiscardRuntimeGitPaths(
{
settings: useAppStore.getState().settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
filePaths
)
for (const relativePath of filePaths) {
notifyEditorExternalFileChange({
worktreeId: activeWorktreeId,

View File

@ -721,6 +721,11 @@ export function ResourceUsageStatusSegment({
// Why: Space scans can finish after the user backs out of the full page or
// closes this popover; the status-bar trigger becomes the handoff point.
useEffect(() => {
if (runtimeEnvironmentActive) {
setSpaceScanReady(false)
previousSpaceScanningRef.current = false
return
}
const scannedAt = workspaceSpaceScannedAt
const wasScanning = previousSpaceScanningRef.current
const scanCompleted =
@ -738,7 +743,14 @@ export function ResourceUsageStatusSegment({
}
previousSpaceScanningRef.current = workspaceSpaceScanning
}, [activeView, open, spaceScanReady, workspaceSpaceScannedAt, workspaceSpaceScanning])
}, [
activeView,
open,
runtimeEnvironmentActive,
spaceScanReady,
workspaceSpaceScannedAt,
workspaceSpaceScanning
])
// Poll memory + sessions when popover is open. Sessions also poll in the
// background at a slower rate so the badge count stays reasonably fresh
@ -976,9 +988,12 @@ export function ResourceUsageStatusSegment({
}, [])
const handleOpenWorkspaceCleanup = useCallback((): void => {
if (runtimeEnvironmentActive) {
return
}
setOpen(false)
queueMicrotask(() => openModal('workspace-cleanup'))
}, [openModal])
}, [openModal, runtimeEnvironmentActive])
const handleKillSession = useCallback(
(session: UnifiedSessionRow): void => {
@ -1072,10 +1087,12 @@ export function ResourceUsageStatusSegment({
type="button"
className="relative inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
aria-label={
spaceScanReady ? 'Resource manager, Space scan ready' : 'Resource manager'
spaceScanReady && !runtimeEnvironmentActive
? 'Resource manager, Space scan ready'
: 'Resource manager'
}
>
{spaceScanReady ? (
{spaceScanReady && !runtimeEnvironmentActive ? (
<span
className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary"
aria-hidden="true"
@ -1114,7 +1131,9 @@ export function ResourceUsageStatusSegment({
Resource Manager {memBadgeLabel} · {sessions.length} session
{sessions.length === 1 ? '' : 's'}
</div>
{spaceScanReady ? <div className="text-primary">Space scan ready</div> : null}
{spaceScanReady && !runtimeEnvironmentActive ? (
<div className="text-primary">Space scan ready</div>
) : null}
</div>
</TooltipContent>
</Tooltip>
@ -1361,32 +1380,38 @@ export function ResourceUsageStatusSegment({
</div>
</div>
<div className="border-t border-border/50 px-3 py-2 shrink-0">
<button
type="button"
onClick={handleOpenWorkspaceCleanup}
className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
<span className="min-w-0 truncate px-4 text-center">
delete inactive workspaces ({oldWorkspaceCount})
</span>
<ChevronRight
className="absolute right-2.5 size-3.5 text-muted-foreground"
aria-hidden
/>
</button>
{orphanCount > 0 ? (
<button
type="button"
onClick={() => void handleKillOrphans()}
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
Kill {orphanCount} orphan terminal{orphanCount === 1 ? '' : 's'}
</button>
) : null}
</div>
{!runtimeEnvironmentActive || orphanCount > 0 ? (
<div className="border-t border-border/50 px-3 py-2 shrink-0">
{!runtimeEnvironmentActive ? (
<button
type="button"
onClick={handleOpenWorkspaceCleanup}
className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
<span className="min-w-0 truncate px-4 text-center">
delete inactive workspaces ({oldWorkspaceCount})
</span>
<ChevronRight
className="absolute right-2.5 size-3.5 text-muted-foreground"
aria-hidden
/>
</button>
) : null}
{orphanCount > 0 ? (
<button
type="button"
onClick={() => void handleKillOrphans()}
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
Kill {orphanCount} orphan terminal{orphanCount === 1 ? '' : 's'}
</button>
) : null}
</div>
) : null}
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
{!runtimeEnvironmentActive ? (
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
) : null}
</PopoverContent>
{/* Why: Radix Dialog must not be a descendant of PopoverContent when
the popover unmounts (e.g. clicking outside, focus moving to the

View File

@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
bulkDiscardRuntimeGitPaths,
bulkStageRuntimeGitPaths,
cancelRuntimeGenerateCommitMessage,
commitRuntimeGit,
generateRuntimeCommitMessage,
getRuntimeGitDiff,
getRuntimeGitStatus,
pushRuntimeGit
@ -15,8 +18,11 @@ import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
const gitStatus = vi.fn()
const gitDiff = vi.fn()
const gitBulkStage = vi.fn()
const gitBulkDiscard = vi.fn()
const gitCommit = vi.fn()
const gitPush = vi.fn()
const gitGenerateCommitMessage = vi.fn()
const gitCancelGenerateCommitMessage = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
const runtimeCall = vi.fn()
@ -26,8 +32,11 @@ beforeEach(() => {
gitStatus.mockReset()
gitDiff.mockReset()
gitBulkStage.mockReset()
gitBulkDiscard.mockReset()
gitCommit.mockReset()
gitPush.mockReset()
gitGenerateCommitMessage.mockReset()
gitCancelGenerateCommitMessage.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
runtimeCall.mockReset()
@ -40,8 +49,11 @@ beforeEach(() => {
status: gitStatus,
diff: gitDiff,
bulkStage: gitBulkStage,
bulkDiscard: gitBulkDiscard,
commit: gitCommit,
push: gitPush
push: gitPush,
generateCommitMessage: gitGenerateCommitMessage,
cancelGenerateCommitMessage: gitCancelGenerateCommitMessage
},
runtime: { call: runtimeCall },
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
@ -161,7 +173,7 @@ describe('runtime git client', () => {
})
})
it('routes bulk stage and remote operations through the active runtime', async () => {
it('routes bulk mutations and remote operations through the active runtime', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
@ -175,7 +187,10 @@ describe('runtime git client', () => {
}
await bulkStageRuntimeGitPaths(context, ['a.ts', 'b.ts'])
await bulkDiscardRuntimeGitPaths(context, ['c.ts', 'd.ts'])
await commitRuntimeGit(context, 'feat: test')
await generateRuntimeCommitMessage(context)
await cancelRuntimeGenerateCommitMessage(context)
await pushRuntimeGit(context, { publish: true, pushTarget: { remote: 'origin' } as never })
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
@ -185,16 +200,75 @@ describe('runtime git client', () => {
timeoutMs: 15_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
selector: 'env-1',
method: 'git.bulkDiscard',
params: { worktree: 'wt-1', filePaths: ['c.ts', 'd.ts'] },
timeoutMs: 15_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, {
selector: 'env-1',
method: 'git.commit',
params: { worktree: 'wt-1', message: 'feat: test' },
timeoutMs: 30_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, {
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, {
selector: 'env-1',
method: 'git.generateCommitMessage',
params: { worktree: 'wt-1' },
timeoutMs: 75_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, {
selector: 'env-1',
method: 'git.cancelGenerateCommitMessage',
params: { worktree: 'wt-1' },
timeoutMs: 5_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, {
selector: 'env-1',
method: 'git.push',
params: { worktree: 'wt-1', publish: true, pushTarget: { remote: 'origin' } },
timeoutMs: 30_000
})
})
it('passes commit-message settings to the active runtime', async () => {
const commitMessageAi = {
enabled: true,
agentId: 'codex' as const,
selectedModelByAgent: { codex: 'gpt-5.3-codex-spark' },
selectedThinkingByModel: { 'gpt-5.3-codex-spark': 'medium' },
customPrompt: 'Prefer concise subjects.',
customAgentCommand: ''
}
const agentCmdOverrides = { codex: 'codex --profile work' }
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: { success: true, message: 'feat: test' },
_meta: { runtimeId: 'remote-runtime' }
})
await generateRuntimeCommitMessage({
settings: {
activeRuntimeEnvironmentId: 'env-1',
commitMessageAi,
agentCmdOverrides,
enableGitHubAttribution: true
},
worktreeId: 'wt-1',
worktreePath: '/repo'
})
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'git.generateCommitMessage',
params: {
worktree: 'wt-1',
commitMessageAi,
agentCmdOverrides,
enableGitHubAttribution: true
},
timeoutMs: 75_000
})
})
})

View File

@ -12,13 +12,41 @@ import type {
} from '../../../shared/types'
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
export type RuntimeGenerateCommitMessageResult =
| { success: true; message: string; agentLabel?: string }
| { success: false; error: string; canceled?: boolean }
type RuntimeGitSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> &
Partial<Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>>
export type RuntimeGitContext = {
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
settings: RuntimeGitSettings | null | undefined
worktreeId: string | null | undefined
worktreePath: string
connectionId?: string
}
function getRuntimeCommitMessageSettings(
settings: RuntimeGitSettings | null | undefined
): Partial<
Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>
> {
if (!settings) {
return {}
}
return {
...(settings.commitMessageAi !== undefined
? { commitMessageAi: settings.commitMessageAi }
: {}),
...(settings.agentCmdOverrides !== undefined
? { agentCmdOverrides: settings.agentCmdOverrides }
: {}),
...(settings.enableGitHubAttribution !== undefined
? { enableGitHubAttribution: settings.enableGitHubAttribution }
: {})
}
}
export function getRuntimeGitScope(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
connectionId: string | undefined
@ -218,6 +246,46 @@ export async function commitRuntimeGit(
)
}
export async function generateRuntimeCommitMessage(
context: RuntimeGitContext
): Promise<RuntimeGenerateCommitMessageResult> {
const target = getActiveRuntimeTarget(context.settings)
if (target.kind === 'local' || !context.worktreeId) {
return window.api.git.generateCommitMessage({
worktreePath: context.worktreePath,
connectionId: context.connectionId
}) as Promise<RuntimeGenerateCommitMessageResult>
}
return callRuntimeRpc<RuntimeGenerateCommitMessageResult>(
target,
'git.generateCommitMessage',
{
worktree: context.worktreeId,
...getRuntimeCommitMessageSettings(context.settings)
},
{ timeoutMs: 75_000 }
)
}
export async function cancelRuntimeGenerateCommitMessage(
context: RuntimeGitContext
): Promise<void> {
const target = getActiveRuntimeTarget(context.settings)
if (target.kind === 'local' || !context.worktreeId) {
await window.api.git.cancelGenerateCommitMessage({
worktreePath: context.worktreePath,
connectionId: context.connectionId
})
return
}
await callRuntimeRpc(
target,
'git.cancelGenerateCommitMessage',
{ worktree: context.worktreeId },
{ timeoutMs: 5_000 }
)
}
export async function stageRuntimeGitPath(
context: RuntimeGitContext,
filePath: string
@ -302,6 +370,27 @@ export async function bulkUnstageRuntimeGitPaths(
)
}
export async function bulkDiscardRuntimeGitPaths(
context: RuntimeGitContext,
filePaths: string[]
): Promise<void> {
const target = getActiveRuntimeTarget(context.settings)
if (target.kind === 'local' || !context.worktreeId) {
await window.api.git.bulkDiscard({
worktreePath: context.worktreePath,
filePaths,
connectionId: context.connectionId
})
return
}
await callRuntimeRpc(
target,
'git.bulkDiscard',
{ worktree: context.worktreeId, filePaths },
{ timeoutMs: 15_000 }
)
}
export async function discardRuntimeGitPath(
context: RuntimeGitContext,
filePath: string

View File

@ -10,6 +10,7 @@ export type SpeechModelManifest = {
language: string
sizeBytes: number
downloadUrl: string
archiveSha256: string
archiveFormat: 'tar.bz2'
files: string[]
sampleRate: number