From 90e49669e6b3f9dc722565263dceb6d93a05fc4a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 21:58:58 -0700 Subject: [PATCH] fix: improve cross-platform compatibility (#2064) --- .../expo/modules/twowayaudio/AudioEngine.kt | 19 ++- src/main/index.ts | 7 + src/main/ipc/filesystem.ts | 56 +------ src/main/ipc/register-core-handlers.ts | 6 +- src/main/runtime/orca-runtime-git.test.ts | 122 +++++++++++++++ src/main/runtime/orca-runtime-git.ts | 142 ++++++++++++++++-- src/main/runtime/orca-runtime.ts | 18 ++- src/main/runtime/rpc/methods/git.test.ts | 91 ++++++++++- src/main/runtime/rpc/methods/git.ts | 56 ++++++- src/main/runtime/runtime-rpc.test.ts | 34 +++++ src/main/runtime/runtime-rpc.ts | 16 +- src/main/speech/model-catalog.ts | 7 + src/main/speech/model-manager.test.ts | 68 +++++++++ src/main/speech/model-manager.ts | 102 +++++++++++-- src/main/speech/tar-executable.ts | 27 ++++ .../commit-message-agent-environment.ts | 52 +++++++ .../editor/rich-markdown-extensions.ts | 17 ++- .../editor/useLocalImageSrc.test.ts | 39 ++++- .../src/components/editor/useLocalImageSrc.ts | 18 +-- .../right-sidebar/SourceControl.tsx | 28 +++- .../status-bar/ResourceUsageStatusSegment.tsx | 85 +++++++---- .../src/runtime/runtime-git-client.test.ts | 80 +++++++++- .../src/runtime/runtime-git-client.ts | 91 ++++++++++- src/shared/speech-types.ts | 1 + 24 files changed, 1041 insertions(+), 141 deletions(-) create mode 100644 src/main/runtime/orca-runtime-git.test.ts create mode 100644 src/main/speech/model-manager.test.ts create mode 100644 src/main/speech/tar-executable.ts create mode 100644 src/main/text-generation/commit-message-agent-environment.ts diff --git a/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt b/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt index 46da8945b..811b29426 100644 --- a/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt +++ b/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt @@ -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?) { @@ -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 -> diff --git a/src/main/index.ts b/src/main/index.ts index ed23f2332..562018b8d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 2223c64ed..b93efc023 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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 = { '.pdf': 'application/pdf' } -export type CommitMessageAgentEnvironmentResolvers = { - prepareForCodexLaunch?: () => string | null - prepareForClaudeLaunch?: () => Promise -} - -function cloneProcessEnv(): Record { - const env: Record = {} - 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). */ diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index f027b2332..04cf9c072 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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' diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts new file mode 100644 index 000000000..c05c67676 --- /dev/null +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -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('../git/status')), + getStagedCommitContext: mocks.getStagedCommitContext +})) + +vi.mock('../text-generation/commit-message-text-generation', async () => ({ + ...(await vi.importActual( + '../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' }) + }) + ) + }) +}) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 51051c994..fec2824a4 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -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 +> + +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 { @@ -74,7 +104,7 @@ export class RuntimeGitCommands { compareAgainstHead?: boolean ): Promise { 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 { 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 { + 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 { 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) { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index bafbee32d..bd47a1574 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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() 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 diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index d3edfde09..1bfdd570b 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 014e08a5a..1b8e47139 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -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, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index be4a615d3..8d66b351c 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -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[] = [] + + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_mismatch', + method: 'status.get', + deviceToken: requestDevice.token + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {}, + 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 }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 27aedb513..6f940d5b6 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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) => void, wsTransport?: WebSocketTransport, - ws?: WebSocket + ws?: WebSocket, + authenticatedDeviceToken?: string | null ): Promise { let request: RpcRequest try { @@ -651,10 +654,17 @@ export class OrcaRuntimeRpcServer { return } - const token = + const requestToken = typeof (request as Record).deviceToken === 'string' ? ((request as Record).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 diff --git a/src/main/speech/model-catalog.ts b/src/main/speech/model-catalog.ts index 7e8290521..8a595128a 100644 --- a/src/main/speech/model-catalog.ts +++ b/src/main/speech/model-catalog.ts @@ -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, diff --git a/src/main/speech/model-manager.test.ts b/src/main/speech/model-manager.test.ts new file mode 100644 index 000000000..6c25e9a5e --- /dev/null +++ b/src/main/speech/model-manager.test.ts @@ -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 + downloadFile: ( + url: string, + dest: string, + expectedSize: number, + modelId: string, + isAborted: () => boolean + ) => Promise +} + +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 }) + } + }) +}) diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index 88979d425..9c25c728b 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -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 { 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 { + 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) => { diff --git a/src/main/speech/tar-executable.ts b/src/main/speech/tar-executable.ts new file mode 100644 index 000000000..c237f43b3 --- /dev/null +++ b/src/main/speech/tar-executable.ts @@ -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}`) +} diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts new file mode 100644 index 000000000..f4ba624b7 --- /dev/null +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -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 +} + +function cloneProcessEnv(): Record { + const env: Record = {} + 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 } +} diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 89e8478d1..cec4a91ef 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -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') } } diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index 736be5a76..7f0526786 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -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: '', + 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() + }) }) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 1f55756fb..9efbd9dc5 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -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 } diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index d603016f4..9a3bc672e 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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, diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index c188581dd..816c9113b 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -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 ? (