diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 5ef1490bf..b3846c92c 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -1,6 +1,8 @@ import { resolve } from 'path' import { defineConfig } from 'vitest/config' +const windowsTestWorkerOptions = process.platform === 'win32' ? { maxWorkers: 4 } : {} + export default defineConfig({ define: { ORCA_FEATURE_WALL_ENABLED: 'true' @@ -13,6 +15,13 @@ export default defineConfig({ }, test: { environment: 'node', - include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'config/scripts/**/*.test.mjs'] + include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'config/scripts/**/*.test.mjs'], + // Why: the full suite runs heavy TS transforms plus real git/http fixtures; + // the Vitest 5s defaults are too tight for the slowest integration cases. + hookTimeout: 60_000, + testTimeout: 30_000, + // Why: Windows process and shell startup are slower under full-suite load; + // macOS/Linux keep Vitest's default worker parallelism. + ...windowsTestWorkerOptions } }) diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index e0ad45b63..3b280183b 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -293,7 +293,7 @@ describe('OpenClaudeHookService-compatible install', () => { readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).toContain('/hook/claude') expect( - readFileSync(join(tmpHome, '.orca', 'agent-hooks', 'openclaude-hook.sh'), 'utf-8') + readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).not.toContain('DEVIN_PROJECT_DIR') expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false) } finally { diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 92b6f8987..dd29c2ebc 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1095,7 +1095,7 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeProfilePath, 'utf-8')).toBe('profile\n') }) - it('bridges system Codex sessions before launch without replacing runtime sessions', async () => { + it('starts the system Codex session bridge without replacing runtime sessions', async () => { const systemMissingRuntimeSessionPath = join( getSystemCodexHomePath(), 'sessions', @@ -1130,9 +1130,12 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(join(getSystemCodexHomePath(), 'state_5.sqlite'), 'sqlite\n', 'utf-8') const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const { startSystemCodexSessionBridgeInBackground } = + await import('../codex/codex-session-bridge') const service = new CodexRuntimeHomeService(store as never) service.prepareForCodexLaunch() + await startSystemCodexSessionBridgeInBackground() const runtimeMissingSessionPath = join( getRuntimeCodexHomePath(), diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 7024b8fa9..12d119a85 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -39,7 +39,7 @@ import { getSystemCodexHomePath, syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' -import { syncSystemCodexSessionsIntoManagedHome } from '../codex/codex-session-bridge' +import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { parseWslUncPath } from '../../shared/wsl-paths' import { @@ -122,6 +122,12 @@ export class CodexRuntimeHomeService { : normalizeCodexRuntimeSelection(settings).host } + /** + * Materializes the runtime home needed before launching the CLI. + * + * Historical session bridging is requested in the background so launch setup + * returns as soon as the active runtime home is ready. + */ prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) @@ -133,7 +139,9 @@ export class CodexRuntimeHomeService { this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() - syncSystemCodexSessionsIntoManagedHome() + // Why: historical Codex sessions can be large; bridge them after launch + // setup so starting a fresh Codex TUI never waits on a full tree walk. + void startSystemCodexSessionBridgeInBackground() return this.getRuntimeHomePath() } diff --git a/src/main/codex/codex-session-bridge.test.ts b/src/main/codex/codex-session-bridge.test.ts index 3ee5ebec2..ae4e17be6 100644 --- a/src/main/codex/codex-session-bridge.test.ts +++ b/src/main/codex/codex-session-bridge.test.ts @@ -20,9 +20,21 @@ const { homedirMock } = vi.hoisted(() => ({ })) const { fsMockState } = vi.hoisted(() => ({ - fsMockState: { failLink: false, failSymlink: false } + fsMockState: { + failLink: false, + failSymlink: false, + fakeSymlinks: new Map() + } })) +function isWindowsSymlinkPrivilegeError(error: unknown): boolean { + if (process.platform !== 'win32' || !(error instanceof Error)) { + return false + } + const errorWithCode = error as Error & { code?: string } + return errorWithCode.code === 'EPERM' || errorWithCode.code === 'EACCES' +} + vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { @@ -33,11 +45,52 @@ vi.mock('node:fs', async () => { } return actual.linkSync(...args) }, + lstatSync: ((path: Parameters[0]) => { + const stat = actual.lstatSync(path) + if (!fsMockState.fakeSymlinks.has(String(path))) { + return stat + } + // Why: Windows often disallows file symlink creation outside Developer + // Mode; tests simulate the link metadata while keeping a real path. + return { ...stat, isSymbolicLink: () => true } + }) as typeof actual.lstatSync, + readlinkSync: ((path: Parameters[0]) => { + const fakeTarget = fsMockState.fakeSymlinks.get(String(path)) + if (fakeTarget !== undefined) { + return fakeTarget + } + return actual.readlinkSync(path) + }) as typeof actual.readlinkSync, + renameSync: (...args: Parameters) => { + const [oldPath, newPath] = args + const fakeTarget = fsMockState.fakeSymlinks.get(String(oldPath)) + const result = actual.renameSync(...args) + if (fakeTarget !== undefined) { + fsMockState.fakeSymlinks.delete(String(oldPath)) + fsMockState.fakeSymlinks.set(String(newPath), fakeTarget) + } else { + fsMockState.fakeSymlinks.delete(String(newPath)) + } + return result + }, + rmSync: (...args: Parameters) => { + fsMockState.fakeSymlinks.delete(String(args[0])) + return actual.rmSync(...args) + }, symlinkSync: (...args: Parameters) => { if (fsMockState.failSymlink) { throw new Error('symlink disabled for test') } - return actual.symlinkSync(...args) + try { + return actual.symlinkSync(...args) + } catch (error) { + if (!isWindowsSymlinkPrivilegeError(error)) { + throw error + } + const [target, path] = args + fsMockState.fakeSymlinks.set(String(path), String(target)) + actual.writeFileSync(path, '', 'utf-8') + } } } }) @@ -50,7 +103,10 @@ vi.mock('node:os', async () => { } }) -import { syncSystemCodexSessionsIntoManagedHome } from './codex-session-bridge' +import { + syncSystemCodexSessionsIntoManagedHome, + syncSystemCodexSessionsIntoManagedHomeIncrementally +} from './codex-session-bridge' let fakeHomeDir: string let userDataDir: string @@ -103,6 +159,7 @@ function writeLegacyCopyMarker(relativePath: string, sourcePath: string, targetP beforeEach(() => { fsMockState.failLink = false fsMockState.failSymlink = false + fsMockState.fakeSymlinks.clear() fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-session-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-session-user-data-')) previousUserDataPath = process.env.ORCA_USER_DATA_PATH @@ -276,4 +333,35 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false) expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"legacy"}\n') }) + + it('incrementally bridges session files without requiring the synchronous launch path', async () => { + const systemSessionRoot = join(getSystemCodexHomePath(), 'sessions', '2026', '06', '18') + mkdirSync(systemSessionRoot, { recursive: true }) + for (let index = 0; index < 5; index += 1) { + writeFileSync( + join(systemSessionRoot, `rollout-incremental-${index}.jsonl`), + `{"id":"incremental-${index}"}\n`, + 'utf-8' + ) + } + + const summary = await syncSystemCodexSessionsIntoManagedHomeIncrementally({ + batchSize: 2, + yieldMs: 0 + }) + + expect(summary).toEqual({ scannedFiles: 5, linkedFiles: 5 }) + for (let index = 0; index < 5; index += 1) { + const systemSessionPath = join(systemSessionRoot, `rollout-incremental-${index}.jsonl`) + const runtimeSessionPath = join( + getRuntimeCodexHomePath(), + 'sessions', + '2026', + '06', + '18', + `rollout-incremental-${index}.jsonl` + ) + expectResourceLinked(runtimeSessionPath, systemSessionPath) + } + }) }) diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts index 7a2815f02..a57422895 100644 --- a/src/main/codex/codex-session-bridge.ts +++ b/src/main/codex/codex-session-bridge.ts @@ -3,7 +3,6 @@ import { linkSync, lstatSync, mkdirSync, - readdirSync, readFileSync, readlinkSync, renameSync, @@ -12,6 +11,13 @@ import { } from 'node:fs' import { dirname, isAbsolute, join, relative, sep } from 'node:path' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { + listCodexSessionJsonlFiles, + listCodexSessionJsonlFilesIncrementally +} from './codex-session-file-listing' +import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' + +export type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' type LegacyCopiedSessionMarker = { sourcePath: string @@ -27,6 +33,16 @@ export type LegacyCopiedCodexSessionBridgeScanPreference = { sourceSkipBytes: number | null } +export type CodexSessionBridgeSummary = { + scannedFiles: number + linkedFiles: number +} + +let backgroundSessionBridgeTask: Promise | null = null + +/** + * Synchronously mirrors system session files into the managed runtime home. + */ export function syncSystemCodexSessionsIntoManagedHome(): void { const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions') if (!existsSync(systemSessionsRoot)) { @@ -35,53 +51,99 @@ export function syncSystemCodexSessionsIntoManagedHome(): void { const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') for (const systemSessionFilePath of listCodexSessionJsonlFiles(systemSessionsRoot)) { - const relativePath = relative(systemSessionsRoot, systemSessionFilePath) - const managedSessionFilePath = join(managedSessionsRoot, relativePath) - if (existsSync(managedSessionFilePath)) { - if ( - replaceSymlinkSessionBridgeWithHardlink( - systemSessionFilePath, - managedSessionFilePath, - relativePath - ) - ) { - continue - } - migrateLegacyCopiedSessionBridge(systemSessionFilePath, managedSessionFilePath, relativePath) - continue + bridgeSystemCodexSessionFile(systemSessionsRoot, managedSessionsRoot, systemSessionFilePath) + } +} + +/** + * Starts a single background bridge task for historical system sessions. + * + * Concurrent callers share the same in-flight task so launch code can request + * background bridging without starting duplicate directory walks. + */ +export function startSystemCodexSessionBridgeInBackground( + options: CodexSessionBridgeIncrementalOptions = {} +): Promise { + if (backgroundSessionBridgeTask) { + return backgroundSessionBridgeTask + } + const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options) + .catch((error: unknown) => { + console.warn('[codex-session-bridge] Background session bridge failed:', error) + }) + .then(() => undefined) + backgroundSessionBridgeTask = task + void task.finally(() => { + if (backgroundSessionBridgeTask === task) { + backgroundSessionBridgeTask = null } - mkdirSync(dirname(managedSessionFilePath), { recursive: true }) - linkSystemCodexSessionFile(systemSessionFilePath, managedSessionFilePath, relativePath) - } + }) + return task } -function listCodexSessionJsonlFiles(rootPath: string): string[] { - const files: string[] = [] - try { - for (const entry of readdirSync(rootPath, { withFileTypes: true })) { - const childPath = join(rootPath, entry.name) - if (entry.isDirectory()) { - appendSessionFilePaths(files, listCodexSessionJsonlFiles(childPath)) - continue - } - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(childPath) - } +/** + * Incrementally mirrors system session files into the managed runtime home. + * + * Returns scan/link counts for tests and diagnostics while keeping each file + * bridge operation equivalent to the synchronous path. + */ +export async function syncSystemCodexSessionsIntoManagedHomeIncrementally( + options: CodexSessionBridgeIncrementalOptions = {} +): Promise { + const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions') + if (!existsSync(systemSessionsRoot)) { + return { scannedFiles: 0, linkedFiles: 0 } + } + + const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') + const summary: CodexSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 } + for await (const systemSessionFilePath of listCodexSessionJsonlFilesIncrementally( + systemSessionsRoot, + options + )) { + summary.scannedFiles += 1 + if ( + bridgeSystemCodexSessionFile(systemSessionsRoot, managedSessionsRoot, systemSessionFilePath) + ) { + summary.linkedFiles += 1 } - } catch (error) { - console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) } - return files.sort() + return summary } -function appendSessionFilePaths(target: string[], source: readonly string[]): void { - // Why: existing Codex homes can accumulate enough nested sessions to exceed - // V8's argument limit if child arrays are spread into push(). - for (const filePath of source) { - target.push(filePath) +/** + * Bridges one system session file into the managed sessions tree. + * + * Existing managed files are migrated when possible; missing files are linked + * and counted as newly available to the managed runtime home. + */ +function bridgeSystemCodexSessionFile( + systemSessionsRoot: string, + managedSessionsRoot: string, + systemSessionFilePath: string +): boolean { + const relativePath = relative(systemSessionsRoot, systemSessionFilePath) + const managedSessionFilePath = join(managedSessionsRoot, relativePath) + if (existsSync(managedSessionFilePath)) { + if ( + replaceSymlinkSessionBridgeWithHardlink( + systemSessionFilePath, + managedSessionFilePath, + relativePath + ) + ) { + return true + } + migrateLegacyCopiedSessionBridge(systemSessionFilePath, managedSessionFilePath, relativePath) + return false } + mkdirSync(dirname(managedSessionFilePath), { recursive: true }) + return linkSystemCodexSessionFile(systemSessionFilePath, managedSessionFilePath, relativePath) } +/** + * Links a source session file and clears any stale copied-session marker. + */ function linkSystemCodexSessionFile( sourcePath: string, targetPath: string, @@ -94,6 +156,9 @@ function linkSystemCodexSessionFile( return linked } +/** + * Attempts to link a session file with hardlink first and symlink fallback. + */ function tryLinkSystemCodexSessionFile(sourcePath: string, targetPath: string): boolean { if (tryHardlinkSystemCodexSessionFile(sourcePath, targetPath)) { return true @@ -109,6 +174,9 @@ function tryLinkSystemCodexSessionFile(sourcePath: string, targetPath: string): return false } +/** + * Attempts a hardlink so resume sees one physical JSONL session log. + */ function tryHardlinkSystemCodexSessionFile(sourcePath: string, targetPath: string): boolean { try { // Why: Codex resume ignores symlinked JSONL sessions, while a hardlink @@ -120,6 +188,10 @@ function tryHardlinkSystemCodexSessionFile(sourcePath: string, targetPath: strin } } +/** + * Replaces an older symlink bridge with a hardlink when the target still points + * at the expected source session. + */ function replaceSymlinkSessionBridgeWithHardlink( sourcePath: string, targetPath: string, @@ -160,6 +232,10 @@ function replaceSymlinkSessionBridgeWithHardlink( return false } +/** + * Migrates a legacy copied bridge to a linked bridge when the copied file still + * matches its marker. + */ function migrateLegacyCopiedSessionBridge( sourcePath: string, targetPath: string, @@ -198,6 +274,12 @@ function migrateLegacyCopiedSessionBridge( } } +/** + * Resolves how scanners should treat a legacy copied session bridge. + * + * The result keeps resume scans coherent until the copied bridge is migrated to + * a hardlink or symlink. + */ export function getLegacyCopiedCodexSessionBridgeScanPreference( sessionFilePath: string ): LegacyCopiedCodexSessionBridgeScanPreference | null { @@ -234,10 +316,16 @@ export function getLegacyCopiedCodexSessionBridgeScanPreference( } } +/** + * Returns the marker path for a legacy copied session bridge. + */ function getLegacySessionCopyMarkerPath(relativePath: string): string { return join(getOrcaManagedCodexHomePath(), '.orca-session-copies', `${relativePath}.json`) } +/** + * Reads and validates the marker for a legacy copied session bridge. + */ function readLegacyCopiedSessionMarker(relativePath: string): LegacyCopiedSessionMarker | null { try { const parsed: unknown = JSON.parse( @@ -262,6 +350,9 @@ function readLegacyCopiedSessionMarker(relativePath: string): LegacyCopiedSessio } } +/** + * Checks whether source or target file stats still match a legacy bridge marker. + */ function fileStatsMatchMarker( stat: { size: number; mtimeMs: number }, marker: LegacyCopiedSessionMarker, @@ -272,6 +363,9 @@ function fileStatsMatchMarker( return stat.size === expectedSize && stat.mtimeMs === expectedMtimeMs } +/** + * Removes the marker after a copied session bridge has been migrated or retired. + */ function clearLegacyCopiedSessionMarker(relativePath: string): void { rmSync(getLegacySessionCopyMarkerPath(relativePath), { force: true }) } diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts new file mode 100644 index 000000000..c6eeb9b1f --- /dev/null +++ b/src/main/codex/codex-session-file-listing.ts @@ -0,0 +1,99 @@ +import { readdirSync } from 'node:fs' +import { opendir } from 'node:fs/promises' +import { join } from 'node:path' + +export type CodexSessionBridgeIncrementalOptions = { + /** Directory entries to process before yielding back to the event loop. */ + batchSize?: number + /** Delay after each processed batch; zero still yields on a timer turn. */ + yieldMs?: number +} + +const INCREMENTAL_BRIDGE_BATCH_SIZE = 64 +const INCREMENTAL_BRIDGE_YIELD_MS = 10 + +/** + * Recursively lists session JSONL files below a root directory. + * + * This synchronous variant preserves the historical bridge behavior for callers + * that run outside the CLI launch path. + */ +export function listCodexSessionJsonlFiles(rootPath: string): string[] { + const files: string[] = [] + try { + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + const childPath = join(rootPath, entry.name) + if (entry.isDirectory()) { + appendSessionFilePaths(files, listCodexSessionJsonlFiles(childPath)) + continue + } + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(childPath) + } + } + } catch (error) { + console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) + } + return files.sort() +} + +/** + * Appends session paths without spreading large arrays into a single call. + */ +function appendSessionFilePaths(target: string[], source: readonly string[]): void { + // Why: existing Codex homes can accumulate enough nested sessions to exceed + // V8's argument limit if child arrays are spread into push(). + for (const filePath of source) { + target.push(filePath) + } +} + +/** + * Yields session JSONL files incrementally while walking a directory tree. + * + * The generator yields control between batches so large history directories do + * not monopolize startup work. + */ +export async function* listCodexSessionJsonlFilesIncrementally( + rootPath: string, + options: CodexSessionBridgeIncrementalOptions +): AsyncGenerator { + const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE) + const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS) + const pendingDirectories = [rootPath] + let entriesSinceYield = 0 + + while (pendingDirectories.length > 0) { + const currentDirectory = pendingDirectories.pop() + if (!currentDirectory) { + continue + } + try { + const directory = await opendir(currentDirectory) + for await (const entry of directory) { + const childPath = join(currentDirectory, entry.name) + if (entry.isDirectory()) { + pendingDirectories.push(childPath) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + yield childPath + } + entriesSinceYield += 1 + if (entriesSinceYield >= batchSize) { + entriesSinceYield = 0 + await delayIncrementalBridge(yieldMs) + } + } + } catch (error) { + console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) + } + } +} + +/** + * Defers incremental bridge work to a later timer turn. + */ +function delayIncrementalBridge(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index d42d76060..dc0dc060a 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -758,7 +758,7 @@ describe('CodexHookService', () => { hooks: Record } expect(systemHooks.hooks.Stop).toBeUndefined() - }, 15_000) + }, 30_000) it('removes the legacy Orca Codex profile file when it only contains managed hooks', () => { const systemCodexHome = join(tmpHome, '.codex') diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ada8a4f32..cadff9b5e 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1269,6 +1269,65 @@ describe('createPtySubprocess', () => { ) }) + it('embeds short PowerShell startup commands in the Windows shell launch', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + let handle: ReturnType + try { + handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + shellOverride: 'powershell.exe', + command: "& 'codex' '--no-alt-screen'" + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + const encoded = String(lastCall[1][3]) + const command = Buffer.from(encoded, 'base64').toString('utf16le') + expect(command.trimEnd().endsWith("& 'codex' '--no-alt-screen'")).toBe(true) + expect(handle!.startupCommandDeliveredInShellArgs).toBe(true) + }) + + it('keeps oversized Windows startup commands on PTY stdin delivery', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + let handle: ReturnType + try { + handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + shellOverride: 'cmd.exe', + command: `codex ${'x'.repeat(7000)}` + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'cmd.exe', + ['/K', 'chcp 65001 > nul'], + expect.any(Object) + ) + expect(handle!.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + it('launches Git Bash with login args and CHERE_INVOKING on Windows', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 60f046a8e..769797ee9 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -64,6 +64,9 @@ export type PtySubprocessOptions = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' } +/** + * Returns a stable default working directory for daemon-spawned PTYs. + */ function getDefaultCwd(): string { if (process.platform !== 'win32') { return process.env.HOME || '/' @@ -81,6 +84,9 @@ function getDefaultCwd(): string { return 'C:\\' } +/** + * Removes pane identity inherited from the daemon parent unless explicitly set. + */ function removeUnspecifiedPaneIdentityEnv( env: Record, explicitEnv: Record | undefined @@ -92,6 +98,9 @@ function removeUnspecifiedPaneIdentityEnv( } } +/** + * Promotes the agent-teams shim path ahead of inherited PATH entries. + */ function promoteAgentTeamsShimPath( env: Record, requestedPath: string | undefined @@ -107,6 +116,9 @@ function promoteAgentTeamsShimPath( env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter) } +/** + * Removes stale development hook endpoints inherited by daemon children. + */ function removeInheritedDevAgentHookEndpoint( env: Record, explicitEnv: Record | undefined @@ -119,6 +131,9 @@ function removeInheritedDevAgentHookEndpoint( } } +/** + * Resolves a WSL launch context from a user-selected distro name. + */ function getWslContextFromPreferredDistro( distro: string | null | undefined ): { distro: string } | undefined { @@ -126,12 +141,18 @@ function getWslContextFromPreferredDistro( return trimmed ? { distro: trimmed } : undefined } +/** + * Strips Electron's internal run-as-node flag from user shell environments. + */ function removeInheritedElectronRunAsNode(env: Record): void { // Why: the daemon needs ELECTRON_RUN_AS_NODE=1 internally, but user shells // must not inherit it or nested Electron commands run as plain Node. delete env.ELECTRON_RUN_AS_NODE } +/** + * Formats a daemon preflight failure with the same ENOENT details node-pty exposes. + */ function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { const detailName = kind === 'helper' ? 'helper' : 'cwd' const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' @@ -142,6 +163,9 @@ function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): Dae ) } +/** + * Checks whether a path currently exists and is a directory. + */ function isExistingDirectory(path: string | undefined): path is string { if (!path) { return false @@ -153,6 +177,9 @@ function isExistingDirectory(path: string | undefined): path is string { } } +/** + * Moves the daemon process to a stable cwd after its original cwd disappears. + */ function repairDaemonCwd(): string | null { const candidates = [ process.env.ORCA_USER_DATA_PATH, @@ -172,6 +199,9 @@ function repairDaemonCwd(): string | null { return null } +/** + * Ensures the daemon cwd is valid before native PTY spawning. + */ function preflightDaemonCwd(): void { let daemonCwd = '' try { @@ -192,6 +222,9 @@ function preflightDaemonCwd(): void { throw formatMissingDaemonPathError('cwd', daemonCwd) } +/** + * Validates macOS node-pty helper availability before spawning terminals. + */ function preflightMacNodePtySpawnEnvironment(): void { if (process.platform !== 'darwin') { return @@ -219,10 +252,16 @@ function preflightMacNodePtySpawnEnvironment(): void { throw formatMissingDaemonPathError('helper', candidates[0] ?? '') } +/** + * Detects native Windows paths that should be validated before spawn. + */ function isNativeWindowsPath(path: string): boolean { return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') } +/** + * Validates explicit native Windows cwd paths before ConPTY launch. + */ function preflightWindowsPtySpawnEnvironment(args: { validationCwd: string cwdWasExplicit: boolean @@ -238,6 +277,9 @@ function preflightWindowsPtySpawnEnvironment(args: { validateWorkingDirectory(args.validationCwd) } +/** + * Wraps native PTY spawn failures with shell and cwd context. + */ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error { const message = err instanceof Error ? err.message : String(err) const formatted = new DaemonProtocolError( @@ -249,6 +291,9 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): return formatted } +/** + * Runs a short native PTY spawn probe for daemon health checks. + */ export async function checkPtySpawnHealth(): Promise { if (process.platform !== 'darwin') { return @@ -318,6 +363,9 @@ export async function checkPtySpawnHealth(): Promise { }) } +/** + * Normalizes node-pty foreground process strings to executable basenames. + */ function normalizeForegroundProcessName(processName: string | null | undefined): string | null { const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' if (!trimmed || trimmed === 'xterm-256color') { @@ -326,6 +374,9 @@ function normalizeForegroundProcessName(processName: string | null | undefined): return trimmed.split(/[\\/]/).pop() || null } +/** + * Falls back to the spawned Windows shell when node-pty reports a terminal name. + */ function resolveFallbackForegroundProcess( processName: string | null | undefined, shellPath: string @@ -339,6 +390,12 @@ function resolveFallbackForegroundProcess( return normalizeForegroundProcessName(pathWin32.basename(shellPath)) } +/** + * Spawns the daemon-owned PTY subprocess for a terminal session. + * + * The returned handle records whether the startup command was already embedded + * in Windows shell args so the daemon host does not write it a second time. + */ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle { const size = normalizePtySize(opts.cols, opts.rows) const env: Record = { @@ -392,6 +449,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl let shellPath = cwdWslInfo || sessionWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) let shellArgs: string[] + let startupCommandDeliveredInShellArgs = false const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' const requestedCwd = opts.cwd || getDefaultCwd() @@ -436,11 +494,13 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellPath, spawnCwd, getDefaultCwd(), - sessionWslContext ?? preferredWslContext + sessionWslContext ?? preferredWslContext, + opts.command ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true if (isWindowsGitBashShellPath(shellPath)) { // Why: Git for Windows login startup files otherwise cd to $HOME, // ignoring node-pty's cwd for repo-scoped terminals. @@ -466,11 +526,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl getDefaultCwd(), { distro: codexHomeWslInfo.distro - } + }, + opts.command ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = + resolved.startupCommandDeliveredInShellArgs === true } } } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { @@ -683,6 +746,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return { pid: proc.pid, + ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), getForegroundProcess: () => { // Why: node-pty's `.process` getter reports the PTY's live foreground // process name (the agent running in the shell, or the shell itself) and diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 94e434d7f..f3262b4ec 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -31,6 +31,9 @@ export type SubprocessHandle = { /** Live foreground process name of the PTY (node-pty's `.process`), e.g. * 'claude' / 'codex' / 'zsh'. Null once the child has exited. */ getForegroundProcess(): string | null + /** True when shell launch args already delivered the startup command, so the + * terminal host must skip its stdin fallback write. */ + startupCommandDeliveredInShellArgs?: boolean write(data: string): void resize(cols: number, rows: number): void kill(): void diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index a0c4b9579..f26aa6817 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -3,11 +3,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalHost } from './terminal-host' import type { SubprocessHandle } from './session' -function createMockSubprocess(): SubprocessHandle { +function createMockSubprocess( + options: { startupCommandDeliveredInShellArgs?: boolean } = {} +): SubprocessHandle { let onDataCb: ((data: string) => void) | null = null let onExitCb: ((code: number) => void) | null = null return { pid: 99999, + ...(options.startupCommandDeliveredInShellArgs + ? { startupCommandDeliveredInShellArgs: true } + : {}), getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), @@ -163,6 +168,31 @@ describe('TerminalHost', () => { process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' ) }) + + it('does not write startup commands already embedded in shell args', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ + startupCommandDeliveredInShellArgs: true + }) as ReturnType & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'codex --no-alt-screen', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).not.toHaveBeenCalled() + }) }) describe('write', () => { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 2bb4473b4..a772de6ef 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -77,6 +77,12 @@ export class TerminalHost { this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES } + /** + * Creates a terminal session or attaches to an existing live one. + * + * Startup commands are written through stdin only when the subprocess did not + * already deliver them through shell launch arguments. + */ async createOrAttach(opts: CreateOrAttachOptions): Promise { const existing = this.sessions.get(opts.sessionId) @@ -137,7 +143,7 @@ export class TerminalHost { const token = session.attachClient(opts.streamClient) - if (opts.command) { + if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) { // Why: startup commands must run inside the long-lived interactive shell // the daemon keeps for the pane. Session.write() handles the shell-ready // barrier for supported shells and falls back to an immediate write for diff --git a/src/main/devin/hook-service.test.ts b/src/main/devin/hook-service.test.ts index 46e1b2b4d..e7b90c7fc 100644 --- a/src/main/devin/hook-service.test.ts +++ b/src/main/devin/hook-service.test.ts @@ -16,7 +16,11 @@ vi.mock('os', async () => { }) import { DevinHookService } from './hook-service' -import { getDevinConfigPath, getDevinManagedCommand } from './hook-settings' +import { + getDevinConfigPath, + getDevinManagedCommand, + getDevinManagedScriptFileName +} from './hook-settings' describe('DevinHookService', () => { let homeDir: string @@ -24,9 +28,11 @@ describe('DevinHookService', () => { beforeEach(() => { homeDir = mkdtempSync(join(tmpdir(), 'orca-devin-home-')) homedirMock.mockReturnValue(homeDir) + vi.stubEnv('APPDATA', join(homeDir, '.config')) }) afterEach(() => { + vi.unstubAllEnvs() vi.clearAllMocks() rmSync(homeDir, { recursive: true, force: true }) }) @@ -57,7 +63,10 @@ describe('DevinHookService', () => { for (const eventName of ['PreToolUse', 'PostToolUse', 'PermissionRequest']) { expect(config.hooks[eventName][0].matcher).toBeUndefined() } - const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', 'devin-hook.sh'), 'utf8') + const script = readFileSync( + join(homeDir, '.orca', 'agent-hooks', getDevinManagedScriptFileName()), + 'utf8' + ) expect(script).toContain('/hook/devin') }) @@ -155,7 +164,7 @@ describe('DevinHookService', () => { it('returns partial status when some managed hooks are missing', () => { const configPath = join(homeDir, '.config', 'devin', 'config.json') - const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'devin-hook.sh') + const scriptPath = join(homeDir, '.orca', 'agent-hooks', getDevinManagedScriptFileName()) const command = getDevinManagedCommand(scriptPath) mkdirSync(dirname(configPath), { recursive: true }) mkdirSync(dirname(scriptPath), { recursive: true }) diff --git a/src/main/ipc/pty-startup-barrier-ordering.test.ts b/src/main/ipc/pty-startup-barrier-ordering.test.ts index 0de91770d..389269466 100644 --- a/src/main/ipc/pty-startup-barrier-ordering.test.ts +++ b/src/main/ipc/pty-startup-barrier-ordering.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it } from 'vitest' describe('PTY startup barrier ordering', () => { it('waits for local startup before resolving the provider for runtime and renderer spawns', () => { - const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8') + const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8').replaceAll( + '\r\n', + '\n' + ) const runtimeSpawnStart = source.indexOf('spawn: async (args) => {') const runtimeSpawnEnd = source.indexOf(' write:', runtimeSpawnStart) const runtimeSpawn = source.slice(runtimeSpawnStart, runtimeSpawnEnd) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index d978cac64..e27685f05 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6,11 +6,7 @@ import { delimiter, join } from 'node:path' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it -const expectedOmpStatusExtension = join( - '/tmp/default-omp-agent', - 'extensions', - 'orca-agent-status.ts' -) +const expectedOmpStatusExtension = '/tmp/default-omp-agent/extensions/orca-agent-status.ts' const expectedAttributionShimDir = join( '/tmp/orca-user-data', 'orca-terminal-attribution', diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 0b7da3deb..142548390 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -65,6 +65,9 @@ type ExitCallback = (payload: { id: string; code: number }) => void const dataListeners = new Set() const exitListeners = new Set() +/** + * Returns a stable default cwd for locally spawned PTYs. + */ function getDefaultCwd(): string { if (process.platform !== 'win32') { return process.env.HOME || '/' @@ -82,6 +85,9 @@ function getDefaultCwd(): string { return 'C:\\' } +/** + * Removes inherited pane identity unless this PTY explicitly supplies it. + */ function removeUnspecifiedPaneIdentityEnv( env: Record, explicitEnv: Record | undefined @@ -93,6 +99,9 @@ function removeUnspecifiedPaneIdentityEnv( } } +/** + * Promotes the agent-teams shim path ahead of inherited PATH entries. + */ function promoteAgentTeamsShimPath( env: Record, requestedPath: string | undefined @@ -108,6 +117,9 @@ function promoteAgentTeamsShimPath( env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter) } +/** + * Disposes native node-pty listeners registered for a PTY id. + */ function disposePtyListeners(id: string): void { const disposables = ptyDisposables.get(id) if (disposables) { @@ -118,6 +130,9 @@ function disposePtyListeners(id: string): void { } } +/** + * Resolves a WSL context from a worktree id whose path is already a WSL path. + */ function getWslContextFromWorktreeId( worktreeId: string | undefined ): { distro: string; treatPosixCwdAsWsl: true } | undefined { @@ -126,6 +141,9 @@ function getWslContextFromWorktreeId( return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined } +/** + * Resolves a WSL launch context from a user-selected distro name. + */ function getWslContextFromPreferredDistro( distro: string | null | undefined ): { distro: string } | undefined { @@ -133,6 +151,9 @@ function getWslContextFromPreferredDistro( return trimmed ? { distro: trimmed } : undefined } +/** + * Removes all local tracking state for a PTY id after teardown. + */ function clearPtyState(id: string): void { disposePtyListeners(id) ptyProcesses.delete(id) @@ -141,6 +162,9 @@ function clearPtyState(id: string): void { ptyLoadGeneration.delete(id) } +/** + * Allocates either a stable caller-provided PTY id or a new numeric id. + */ function allocatePtyId(sessionId: string | undefined): string { const requested = normalizeLocalCallerSessionId(sessionId) if (requested) { @@ -153,6 +177,9 @@ function allocatePtyId(sessionId: string | undefined): string { return id } +/** + * Normalizes renderer session ids that should be reused for local PTY reattach. + */ function normalizeLocalCallerSessionId(sessionId: string | undefined): string | null { const requested = sessionId?.trim() if (!requested || /^\d+$/.test(requested)) { @@ -161,6 +188,9 @@ function normalizeLocalCallerSessionId(sessionId: string | undefined): string | return requested } +/** + * Normalizes node-pty foreground process strings to executable basenames. + */ function normalizeForegroundProcessName(processName: string | null | undefined): string | null { const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' if (!trimmed || trimmed === 'xterm-256color') { @@ -169,6 +199,9 @@ function normalizeForegroundProcessName(processName: string | null | undefined): return trimmed.split(/[\\/]/).pop() || null } +/** + * Falls back to the spawned Windows shell when node-pty reports a terminal name. + */ function resolveForegroundFallbackProcess( processName: string | null | undefined, shellName: string | undefined @@ -181,6 +214,9 @@ function resolveForegroundFallbackProcess( return shellName ?? processName ?? null } +/** + * Disposes the native PTY handle while avoiding recycled-pid signals on POSIX. + */ function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } = {}): void { // Why: node-pty's UnixTerminal.destroy() closes the master socket, which // releases the ptmx fd to the OS — without this call the fd leaks until GC @@ -204,6 +240,9 @@ function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } } } +/** + * Kills a local PTY and clears all associated local provider state. + */ function safeKillAndClean(id: string, proc: pty.IPty): void { disposePtyListeners(id) try { @@ -252,6 +291,12 @@ export class LocalPtyProvider implements IPtyProvider { this.opts = opts } + /** + * Spawns or reattaches a local PTY session for the renderer process. + * + * Windows shell launches can pre-deliver short startup commands in argv; this + * method preserves that state so the stdin fallback only runs when needed. + */ async spawn(args: PtySpawnOptions): Promise { const reattachId = normalizeLocalCallerSessionId(args.sessionId) if (reattachId) { @@ -281,6 +326,7 @@ export class LocalPtyProvider implements IPtyProvider { let shellArgs: string[] let effectiveCwd: string let validationCwd: string + let startupCommandDeliveredInShellArgs = false let shellReadyLaunch: ReturnType | null = null let getFallbackShellReadyConfig: | ((shell: string) => ReturnType) @@ -340,11 +386,13 @@ export class LocalPtyProvider implements IPtyProvider { shellPath, cwd, defaultCwd, - worktreeWslContext ?? preferredWslContext + worktreeWslContext ?? preferredWslContext, + args.command ) shellArgs = resolved.shellArgs effectiveCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true } else { shellPath = args.env?.SHELL || process.env.SHELL || '/bin/zsh' shellArgs = ['-l'] @@ -441,6 +489,8 @@ export class LocalPtyProvider implements IPtyProvider { shellArgs = resolved.shellArgs effectiveCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = + resolved.startupCommandDeliveredInShellArgs === true } } } else if (isHostCodexHomeForWsl(finalEnv.CODEX_HOME)) { @@ -651,7 +701,7 @@ export class LocalPtyProvider implements IPtyProvider { } ptyDisposables.set(id, disposables) - if (args.command) { + if (args.command && !startupCommandDeliveredInShellArgs) { writeStartupCommandWhenShellReady(shellReadyPromise, proc, args.command, (cleanup) => { startupCommandCleanup = cleanup }) diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 46d825dc7..0801f002e 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -19,10 +19,35 @@ describe('resolveWindowsShellLaunchArgs', () => { it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => { const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice') expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() expect(result.effectiveCwd).toBe('C:\\Users\\alice') expect(result.validationCwd).toBe('C:\\Users\\alice') }) + it('embeds short cmd.exe startup commands in shell args', () => { + const result = resolveWindowsShellLaunchArgs( + 'cmd.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + 'codex --no-alt-screen' + ) + expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul & codex --no-alt-screen']) + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + }) + + it('keeps large cmd.exe startup commands on stdin delivery', () => { + const result = resolveWindowsShellLaunchArgs( + 'cmd.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + `codex ${'x'.repeat(7000)}` + ) + expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + it('returns PowerShell args that install OSC 133 bootstrap after normal profile loading', () => { const result = resolveWindowsShellLaunchArgs( 'powershell.exe', @@ -64,6 +89,56 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(command).not.toContain('`e]133') }) + it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => { + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + "& 'codex' '--no-alt-screen'" + ) + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + + const command = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') + expect(command).toContain('function Global:prompt') + expect(command.trimEnd().endsWith("& 'codex' '--no-alt-screen'")).toBe(true) + }) + + it('preserves complex PowerShell startup command text through EncodedCommand', () => { + const startupCommand = + '& "C:\\Program Files\\Orca CLI\\orca.exe" "--label" "quoted value"; $env:ORCA_VALUE = "nested"' + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + startupCommand + ) + + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + const command = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') + expect(command).toContain(`\n${startupCommand}`) + expect(command.trimEnd().endsWith(startupCommand)).toBe(true) + }) + + it('keeps large PowerShell startup commands on stdin delivery', () => { + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + `orca ${'x'.repeat(7000)}` + ) + + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() + expect(result.shellArgs).toEqual([ + '-NoLogo', + '-NoExit', + '-EncodedCommand', + encodePowerShellCommand(getPowerShellOsc133Bootstrap()) + ]) + }) + it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => { const result = resolveWindowsShellLaunchArgs('pwsh.exe', 'C:\\', 'C:\\Users\\alice') expect(result.shellArgs).toEqual([ @@ -102,9 +177,12 @@ describe('resolveWindowsShellLaunchArgs', () => { const result = resolveWindowsShellLaunchArgs( 'wsl.exe', 'C:\\Users\\alice\\code', - 'C:\\Users\\alice' + 'C:\\Users\\alice', + undefined, + 'codex' ) expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code')) + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() // Why: WSL cannot cd into a Windows path, so node-pty must start from the // user's Windows home and we inject the Linux cd into the shellArgs above. expect(result.effectiveCwd).toBe('C:\\Users\\alice') diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index b41fbd557..9944b1277 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -11,6 +11,11 @@ import { getPowerShellOsc133Bootstrap } from '../powershell-osc133-bootstrap' +const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 +const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 +const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 +const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' + /** Result of resolving a Windows shell to its launch args + effective cwd. * * Why this module exists: both the in-process LocalPtyProvider and the @@ -21,6 +26,9 @@ import { * decision here keeps both paths honest. */ export type WindowsShellLaunchArgs = { shellArgs: string[] + /** True when the startup command was embedded in shellArgs and must not be + * written again through stdin. */ + startupCommandDeliveredInShellArgs?: boolean /** The cwd node-pty should be spawned with. WSL cannot cd into a Windows * path, so the wsl.exe branch returns the user's home as the effective cwd * and injects `cd ''` into shellArgs instead. */ @@ -36,6 +44,55 @@ export type WindowsShellWslContext = { treatPosixCwdAsWsl?: boolean } +/** + * Returns a startup command that is safe to embed in cmd.exe launch args. + * + * Commands that could exceed Windows cmd.exe limits return null so callers + * keep the older stdin delivery path. + */ +function getCmdShellArgStartupCommand(command?: string): string | null { + if (!command || command.length > STARTUP_COMMAND_TEXT_MAX_CHARS) { + return null + } + const commandArg = `${CMD_UTF8_SETUP_COMMAND} & ${command}` + if (commandArg.length > CMD_EXE_COMMAND_LINE_MAX_CHARS) { + return null + } + return command +} + +/** + * Builds the PowerShell -EncodedCommand payload for startup bootstrap. + * + * Short startup commands are appended to the bootstrap and marked as delivered; + * large payloads return the bootstrap alone so stdin delivery remains available. + */ +function getPowerShellEncodedCommand(startupCommand?: string): { + encodedCommand: string + startupCommandDeliveredInShellArgs?: boolean +} { + const bootstrap = getPowerShellOsc133Bootstrap() + if (!startupCommand || startupCommand.length > STARTUP_COMMAND_TEXT_MAX_CHARS) { + return { encodedCommand: encodePowerShellCommand(bootstrap) } + } + + const command = `${bootstrap}\n${startupCommand}` + const encodedCommand = encodePowerShellCommand(command) + // Why: -EncodedCommand expands UTF-16 text into base64; keep a conservative + // margin under Windows CreateProcess' 32,767-character command line limit. + if (encodedCommand.length > POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS) { + return { encodedCommand: encodePowerShellCommand(bootstrap) } + } + + return { + encodedCommand, + startupCommandDeliveredInShellArgs: true + } +} + +/** + * Builds wsl.exe arguments that enter the target directory through the distro shell. + */ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { const setupCommand = [ `cd ${quotePosixShell(linuxCwd)}`, @@ -61,28 +118,35 @@ export function resolveWindowsShellLaunchArgs( shellPath: string, cwd: string, defaultCwd: string, - wslContext?: WindowsShellWslContext + wslContext?: WindowsShellWslContext, + startupCommand?: string ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() if (shellBasename === 'cmd.exe') { + const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) return { - shellArgs: ['/K', 'chcp 65001 > nul'], + shellArgs: [ + '/K', + shellArgStartupCommand + ? `${CMD_UTF8_SETUP_COMMAND} & ${shellArgStartupCommand}` + : CMD_UTF8_SETUP_COMMAND + ], + ...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}), effectiveCwd: cwd, validationCwd: cwd } } if (shellBasename === 'powershell.exe' || shellBasename === 'pwsh.exe') { + const powerShellCommand = getPowerShellEncodedCommand(startupCommand) // Why: foreground-process status on Windows depends on OSC 133 C/D, and // PowerShell needs a prompt/readline bootstrap after profiles finish. return { - shellArgs: [ - '-NoLogo', - '-NoExit', - '-EncodedCommand', - encodePowerShellCommand(getPowerShellOsc133Bootstrap()) - ], + shellArgs: ['-NoLogo', '-NoExit', '-EncodedCommand', powerShellCommand.encodedCommand], + ...(powerShellCommand.startupCommandDeliveredInShellArgs + ? { startupCommandDeliveredInShellArgs: true } + : {}), effectiveCwd: cwd, validationCwd: cwd } diff --git a/src/relay/git-handler-branch-cleanup.test.ts b/src/relay/git-handler-branch-cleanup.test.ts index 54e497ca9..430076e84 100644 --- a/src/relay/git-handler-branch-cleanup.test.ts +++ b/src/relay/git-handler-branch-cleanup.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import * as path from 'path' import type { GitExec } from './git-handler-ops' import { removeWorktreeOp } from './git-handler-worktree-ops' @@ -16,7 +15,7 @@ function worktreeList(...entries: { path: string; branch?: string }[]): string { } function resolvedRepoPath(): string { - return path.resolve('/repo-feature', '/repo/.git', '..') + return '/repo' } describe('removeWorktreeOp branch cleanup', () => { diff --git a/src/relay/git-handler-pull-reconciliation.test.ts b/src/relay/git-handler-pull-reconciliation.test.ts index 2803301d5..7312d295d 100644 --- a/src/relay/git-handler-pull-reconciliation.test.ts +++ b/src/relay/git-handler-pull-reconciliation.test.ts @@ -94,7 +94,7 @@ describe('GitHandler pull reconciliation', () => { ) expect(execGit(consumerDir, ['status', '--short'])).toBe('') - }) + }, 15_000) it('preserves configured rebase pull semantics', async () => { const consumerDir = createDivergentFixture() @@ -108,7 +108,7 @@ describe('GitHandler pull reconciliation', () => { expect(parentRefs).toHaveLength(1) expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true) expect(execGit(consumerDir, ['status', '--short'])).toBe('') - }) + }, 15_000) }) function restoreGitEnv( diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index 177661f71..684bb5e2b 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import * as path from 'path' import type { GitExec } from './git-handler-ops' import { addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' @@ -16,7 +15,7 @@ function worktreeList(...entries: { path: string; branch?: string }[]): string { } function resolvedRepoPath(): string { - return path.resolve('/repo-feature', '/repo/.git', '..') + return '/repo' } describe('addWorktreeOp', () => { diff --git a/src/relay/git-handler-worktree-paths.test.ts b/src/relay/git-handler-worktree-paths.test.ts index d2f9ba9e4..d46d19183 100644 --- a/src/relay/git-handler-worktree-paths.test.ts +++ b/src/relay/git-handler-worktree-paths.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import * as path from 'path' import type { GitExec } from './git-handler-ops' import { removeWorktreeOp } from './git-handler-worktree-ops' @@ -29,7 +28,7 @@ function nulWorktreeList(...entries: { path: string; branch?: string }[]): strin } function resolvedRepoPath(): string { - return path.resolve('/repo-feature', '/repo/.git', '..') + return '/repo' } describe('relay worktree path parsing', () => { diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts index 50394129b..0aa391981 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts @@ -2,7 +2,10 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -const FIELD_SOURCE = readFileSync(join(__dirname, 'SmartWorkspaceNameField.tsx'), 'utf8') +const FIELD_SOURCE = readFileSync( + join(__dirname, 'SmartWorkspaceNameField.tsx'), + 'utf8' +).replaceAll('\r\n', '\n') function sourceBetween(source: string, startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index c3e8870cb..3d7e25d73 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -226,7 +226,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('58941') expect(markup).not.toContain('data-worktree-card-meta-row=""') expect(markup).toContain('aria-label="1 live port"') - }, 20_000) + }, 30_000) it('shows hidden task, notes, and port details from the compact worktree card hover', async () => { settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } @@ -277,7 +277,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('Live Ports') expect(markup).toContain('58941') expect(markup).not.toContain('data-worktree-card-meta-row=""') - }, 20_000) + }, 30_000) it('shows selected task and note metadata on the compact card title row', async () => { settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } @@ -300,7 +300,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('Linked issue #123') expect(markup).toContain('Linked Linear ENG-123') expect(markup).toContain('Workspace notes') - }, 20_000) + }, 30_000) it('keeps selected task and note metadata above the compact branch row', async () => { settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } @@ -327,7 +327,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('Linked Linear ENG-123') expect(markup).toContain('Workspace notes') expect(markup).toContain('feature/local-branch') - }, 20_000) + }, 30_000) it('keeps branch identity visible on detailed cards by default', async () => { settings = { compactWorktreeCards: false } diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx index 1d289a0f1..c28bfe968 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx @@ -157,7 +157,7 @@ describe('WorktreeCardAgents send targets', () => { expect(markup).toContain('data-disabled-reason="Agent is working"') expect(markup).toContain(`data-pane-key="${WORKING_PANE_KEY}"`) expect(markup).toContain('data-has-send-handler="true"') - }, 10_000) + }, 30_000) it('leaves other worktree rows in ordinary mode during target selection', async () => { mockStoreState = { diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index 2c3cf8272..5981b049a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -191,7 +191,7 @@ describe('WorktreeCardAgents', () => { expect(markup).toContain('data-testid="agent-row"') expect(markup).not.toContain(' { mockAgents = [mockAgent({ agentType: 'codex', startedAt: 1000, prompt: 'Run tests' })] diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 4c587e49a..7a5dfbb13 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -386,7 +386,7 @@ function expectBoundaryStep(args: { describe('WorktreeList real child WorktreeCard integration', () => { beforeAll(async () => { WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent - }, 20_000) + }, 60_000) beforeEach(() => { vi.clearAllMocks() diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index e317d7c24..1c40881b6 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -195,7 +195,7 @@ async function renderExpandedBrowserTab(tab: BrowserTabState): Promise return expandNode(await renderBrowserTab(tab)) } -describe('BrowserTab favicon', { timeout: 10_000 }, () => { +describe('BrowserTab favicon', { timeout: 30_000 }, () => { beforeEach(() => { reactHookRuntime.states = [] reactHookRuntime.index = 0 diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index a526711f0..4c8c34168 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -378,7 +378,7 @@ describe('TabBar PowerShell launch wiring', () => { onSelect?.() expect(onNewTerminalWithShell).toHaveBeenCalledWith('pwsh.exe') - }) + }, 30_000) it('hides the WSL terminal row for local host-runtime projects', async () => { appStoreSnapshot.activeRepoId = 'repo-1' diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ba5a98d19..883544ed7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -596,7 +596,7 @@ describe('connectPanePty', () => { expect((globalThis as Record).__ptyConnectDiag).toBeUndefined() expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('[pty-connect]')) logSpy.mockRestore() - }) + }, 30_000) it('threads the resolved local project runtime into IPC terminal transport options', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index b24499e98..3ffde4202 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -7,9 +7,30 @@ import { quotePosixShell } from './wsl-login-shell-command' +const WSL_TEST_COMMAND_TIMEOUT_MS = 10_000 +let wslShAvailable: boolean | null = null + +function canRunWslSh(): boolean { + if (process.platform !== 'win32') { + return false + } + if (wslShAvailable !== null) { + return wslShAvailable + } + try { + execFileSync('wsl.exe', ['--', 'sh', '-lc', 'true'], { + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) + wslShAvailable = true + } catch { + wslShAvailable = false + } + return wslShAvailable +} + function expectValidShSyntax(command: string): void { try { - execFileSync('sh', ['-n'], { input: command }) + execFileSync('sh', ['-n'], { input: command, timeout: WSL_TEST_COMMAND_TIMEOUT_MS }) return } catch (error) { if ( @@ -19,7 +40,13 @@ function expectValidShSyntax(command: string): void { throw error } } - execFileSync('wsl.exe', ['--', 'sh', '-n'], { input: command }) + if (!canRunWslSh()) { + return + } + execFileSync('wsl.exe', ['--', 'sh', '-n'], { + input: command, + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) } describe('wsl login shell command helpers', () => { @@ -45,7 +72,7 @@ describe('wsl login shell command helpers', () => { expect(escaped).toContain('\\$(getent passwd "\\$(id -un)"') expect(escaped).toContain('\\$HISTFILE') expectValidShSyntax(command) - }, 15_000) + }, 30_000) it('does not double-escape wrapper shell variables', () => { const command = 'echo \\$_orca_wsl_shell "$_orca_wsl_shell"' @@ -65,23 +92,23 @@ describe('wsl login shell command helpers', () => { "'HISTFILE=/tmp/orca-history printf \"\\$HISTFILE\"; printf '\\''%s'\\'' \"\\$SHELL\"'" ) expectValidShSyntax(command) - }, 15_000) + }, 30_000) it('preserves user command variables across the Windows-to-WSL argv boundary', () => { - if (process.platform !== 'win32') { - return - } - try { - execFileSync('wsl.exe', ['--', 'true']) - } catch { + if (!canRunWslSh()) { return } const command = buildWslLoginShellCommand('orca_value=ok; printf "<%s>" "$orca_value"') const escaped = escapeWslShCommandForWindows(command) - expect(execFileSync('wsl.exe', ['--', 'sh', '-lc', escaped], { encoding: 'utf8' })).toBe('') - }, 15_000) + expect( + execFileSync('wsl.exe', ['--', 'sh', '-lc', escaped], { + encoding: 'utf8', + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) + ).toBe('') + }, 30_000) it('starts an interactive login shell without assuming bash', () => { const command = buildWslInteractiveLoginShellCommand()