diff --git a/src/main/git/command-runner/git-command-resolution.ts b/src/main/git/command-runner/git-command-resolution.ts index 1e8adf317a1..337e22d1bf3 100644 --- a/src/main/git/command-runner/git-command-resolution.ts +++ b/src/main/git/command-runner/git-command-resolution.ts @@ -1,6 +1,7 @@ +import { wslDistroForCommand } from '../../../shared/wsl-paths' +export { wslDistroForCommand } from '../../../shared/wsl-paths' import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { withTimeout } from '../../../shared/promise-timeout-fallback' -import { parseWslPath } from '../../wsl' import { isWslDirectGitReadCommand } from '../wsl-direct-git-read-commands' import { disableWslGitReadEnvironment, @@ -20,14 +21,6 @@ function wslGitArgs(args: string[], options: GitExecOptions): string[] { return withWslWindowsCheckoutGitConfig(args, options.cwd, options.wslDistro) } -/** The WSL distro a Git command with this cwd runs in, or null when it runs on the host. */ -export function wslDistroForCommand(cwd: string | undefined, override?: string): string | null { - if (process.platform !== 'win32') { - return null - } - return (cwd ? parseWslPath(cwd)?.distro : undefined) ?? override ?? null -} - export function resolveGitCommand( args: string[], options: GitExecOptions, diff --git a/src/main/git/command-runner/wsl-aware-spawn.ts b/src/main/git/command-runner/wsl-aware-spawn.ts index 5567dc941ba..e0fce9a4de7 100644 --- a/src/main/git/command-runner/wsl-aware-spawn.ts +++ b/src/main/git/command-runner/wsl-aware-spawn.ts @@ -1,6 +1,11 @@ import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' -import { resolveCommand } from './wsl-command-resolution' +import { + commandLineLength, + MAX_COMMAND_LINE_CHARS, + WindowsCommandLineBudgetError +} from '../../../shared/windows-command-line-budget' +import { resolveCommand, withWslPreparation } from './wsl-command-resolution' export function wslAwareSpawn( command: string, @@ -11,15 +16,28 @@ export function wslAwareSpawn( useWslLoginShell?: boolean wslShellCommand?: string cwdFailureExitCode?: number + preparation?: { script: string; args: string[] } } ): ChildProcess { - const { wslDistro, useWslLoginShell, wslShellCommand, cwdFailureExitCode, ...spawnOptions } = - options - const resolved = resolveCommand(command, args, options.cwd, wslDistro, { + const { + wslDistro, + useWslLoginShell, + wslShellCommand, + cwdFailureExitCode, + preparation, + ...spawnOptions + } = options + let resolved = resolveCommand(command, args, options.cwd, wslDistro, { useWslLoginShell, wslShellCommand, ...(cwdFailureExitCode === undefined ? {} : { cwdFailureExitCode }) }) + if (preparation && resolved.wsl) { + resolved = withWslPreparation(resolved, preparation) + if (commandLineLength([resolved.binary, ...resolved.args]) > MAX_COMMAND_LINE_CHARS) { + throw new WindowsCommandLineBudgetError() + } + } const spawnStartedAt = performance.now() const child = spawn(resolved.binary, resolved.args, { ...spawnOptions, diff --git a/src/main/git/command-runner/wsl-command-resolution.ts b/src/main/git/command-runner/wsl-command-resolution.ts index d1149790508..d34b5317802 100644 --- a/src/main/git/command-runner/wsl-command-resolution.ts +++ b/src/main/git/command-runner/wsl-command-resolution.ts @@ -208,3 +208,23 @@ function withWslProcessGroupTermination( termination } } + +/** Prefix only the opt-in preparation; retain the resolver's original guest argv intact. */ +export function withWslPreparation( + command: ResolvedCommand, + preparation: { script: string; args: string[] } +): ResolvedCommand { + const execIndex = command.args.indexOf('--exec') + return { + ...command, + args: [ + ...command.args.slice(0, execIndex + 1), + '/bin/sh', + '-c', + preparation.script, + 'orca-generation', + ...preparation.args, + ...command.args.slice(execIndex + 1) + ] + } +} diff --git a/src/main/ssh/ssh-posix-command-wrapper.test.ts b/src/main/ssh/ssh-posix-command-wrapper.test.ts index 4ac663e77ed..e6c8497c655 100644 --- a/src/main/ssh/ssh-posix-command-wrapper.test.ts +++ b/src/main/ssh/ssh-posix-command-wrapper.test.ts @@ -1,3 +1,4 @@ +import { encodePosixPrintfRecords } from '../../shared/posix-printf-encoding' import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { describe, expect, it } from 'vitest' @@ -20,6 +21,21 @@ function shimDir(tools: Record): string { return bin } +it.skipIf(process.platform === 'win32')( + 'keeps incremental printf records byte-bounded and lossless at escape/codepoint boundaries', + () => { + const controls = Array.from({ length: 128 }, (_, index) => String.fromCharCode(index)).join('') + const text = `${'x'.repeat(1023)}🌊${controls}\\c\\012\uFEFF'!%$\`\ud800`.repeat(4) + const chunks = [...encodePosixPrintfRecords(text)] + expect(chunks.every((chunk) => Buffer.byteLength(chunk) <= 1024)).toBe(true) + expect(chunks.every((chunk) => !chunk.includes('\n') && !chunk.includes('\0'))).toBe(true) + const result = spawnSync('/bin/sh', ['-c', 'printf "%b" "$@"', 'fixture', ...chunks]) + expect(result.status).toBe(0) + expect(result.stdout).toEqual(Buffer.from(text)) + expect([...encodePosixPrintfRecords('')]).toEqual(['']) + } +) + describe('wrapRemoteCommandForPosixShell', () => { it('emits one physical line without requiring a remote decoder binary', () => { const wrapped = wrapRemoteCommandForPosixShell("printf 'a\\n'\nprintf 'b\\n'") diff --git a/src/main/ssh/ssh-posix-command-wrapper.ts b/src/main/ssh/ssh-posix-command-wrapper.ts index ac921a7930b..acf68f60d47 100644 --- a/src/main/ssh/ssh-posix-command-wrapper.ts +++ b/src/main/ssh/ssh-posix-command-wrapper.ts @@ -1,34 +1,8 @@ +import { encodePosixPrintfRecords } from '../../shared/posix-printf-encoding' import { gzipSync } from 'node:zlib' import { quotePosixShell } from '../../shared/shell-argument-quoting' -const REMOTE_COMMAND_CHUNK_MAX_BYTES = 1_024 const REMOTE_COMMAND_COMPRESSION_THRESHOLD_BYTES = 8_000 -const REMOTE_COMMAND_PRINTF_ESCAPED_BYTES = new Set([0x21, 0x27, 0x5c]) - -function encodeRemoteCommandForPrintf(command: string): string[] { - const chunks: string[] = [] - let chunk = '' - let chunkBytes = 0 - for (const character of command) { - const codePoint = character.codePointAt(0)! - const isSafePrintableAscii = - codePoint >= 0x20 && codePoint <= 0x7e && !REMOTE_COMMAND_PRINTF_ESCAPED_BYTES.has(codePoint) - const encodedCharacter = - codePoint > 0x7f || isSafePrintableAscii - ? character - : `\\0${codePoint.toString(8).padStart(3, '0')}` - const encodedBytes = codePoint > 0x7f ? Buffer.byteLength(character) : encodedCharacter.length - if (chunkBytes + encodedBytes > REMOTE_COMMAND_CHUNK_MAX_BYTES) { - chunks.push(chunk) - chunk = '' - chunkBytes = 0 - } - chunk += encodedCharacter - chunkBytes += encodedBytes - } - chunks.push(chunk) - return chunks -} /** The compressed wrapper's exit when the host has no base64 or gzip; nothing ran. */ export const REMOTE_COMMAND_DECODER_UNAVAILABLE_EXIT = 125 @@ -39,7 +13,7 @@ export function wrapRemoteCommandForPosixShell( options: { compress?: boolean } = {} ): string { // csh/tcsh split multiline exec strings; rebuild bounded printf chunks without consuming relay stdin. - const encodedChunks = encodeRemoteCommandForPrintf(command) + const encodedChunks = [...encodePosixPrintfRecords(command)] const runDecoded = 'decoded=${decoded%_}; exec /bin/sh -c "$decoded"' const decodeAndRun = `decoded=$(printf %b "$@" && printf _) || exit $?; ${runDecoded}` const chunkArguments = encodedChunks.map(quotePosixShell).join(' ') @@ -53,7 +27,7 @@ export function wrapRemoteCommandForPosixShell( // Compress long scripts before gateways can truncate the exec request. const compressed = gzipSync(Buffer.from(command), { level: 9 }).toString('base64') - const compressedArguments = encodeRemoteCommandForPrintf(compressed) + const compressedArguments = [...encodePosixPrintfRecords(compressed)] .map(quotePosixShell) .join(' ') // Preserve payload stdin and execute only after gzip validation. Missing decoders consume nothing, permitting plain retry; macOS before 13 needs base64 -D. diff --git a/src/main/text-generation/source-control-agent-launch.ts b/src/main/text-generation/source-control-agent-launch.ts index 5d98fa6753b..bd2de32de07 100644 --- a/src/main/text-generation/source-control-agent-launch.ts +++ b/src/main/text-generation/source-control-agent-launch.ts @@ -1,9 +1,10 @@ +import { wslDistroForCommand } from '../../shared/wsl-paths' import { spawnProcess } from '@orca/process-host' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { resolveCliCommand } from '../codex-cli/command' import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' import { guardSupervisedProviderGroup } from '../provider-process/provider-group-orphan-guard' -import { SOURCE_CONTROL_KILL_SITE } from './source-control-local-process' +import { SOURCE_CONTROL_KILL_SITE } from './source-control-agent-termination' import { wslAwareSpawn } from '../git/runner' import { guestNativeSpawnEnv } from '../ipc/wsl-guest-windows-drive-path' import type { @@ -41,8 +42,11 @@ function buildWslLauncherEnv(explicitEnv: NodeJS.ProcessEnv | undefined): NodeJS } export const spawnSourceControlAgent: SpawnSourceControlAgent = (input) => { + const wslDistro = input.preparation + ? wslDistroForCommand(input.cwd, input.wslDistro) + : input.wslDistro const spawnEnv = input.env ?? process.env - if (process.platform === 'win32' && input.wslDistro) { + if (process.platform === 'win32' && wslDistro) { // Apply command assignments after guest login-shell setup, never to the Windows launcher. const assignments = Object.entries(input.commandEnv ?? {}).map( ([key, value]) => `${key}=${value}` @@ -56,8 +60,9 @@ export const spawnSourceControlAgent: SpawnSourceControlAgent = (input) => { env: buildWslLauncherEnv(input.env), stdio: [input.stdinMode, 'pipe', 'pipe'], windowsHide: true, - wslDistro: input.wslDistro, - useWslLoginShell: true + wslDistro, + useWslLoginShell: true, + ...(input.preparation ? { preparation: input.preparation } : {}) } ) as SpawnedSourceControlAgentProcess } diff --git a/src/main/text-generation/source-control-agent-termination.ts b/src/main/text-generation/source-control-agent-termination.ts new file mode 100644 index 00000000000..0b4f60ebe19 --- /dev/null +++ b/src/main/text-generation/source-control-agent-termination.ts @@ -0,0 +1,27 @@ +import { stopSupervisedChildProcess } from '../provider-process/supervised-child-process-stop' +import { terminateWindowsProcessTree } from '../windows-process-tree-kill' +import type { SpawnedSourceControlAgentProcess } from './source-control-text-generation-types' + +export const SOURCE_CONTROL_KILL_SITE = 'source-control-text-generation' + +export async function killSourceControlAgentProcess( + child: SpawnedSourceControlAgentProcess +): Promise { + const pid = child.pid + if (!pid) { + return + } + if (child.supervised) { + await stopSupervisedChildProcess(child, { site: SOURCE_CONTROL_KILL_SITE }) + return + } + if (process.platform === 'win32') { + // Tree-walk refusal must not release the home lock having killed nothing; root handle kill cannot hit a recycled PID. + await terminateWindowsProcessTree(pid, { site: SOURCE_CONTROL_KILL_SITE }) + } + try { + child.kill('SIGKILL') + } catch { + // The process may exit between the PID check and kill. + } +} diff --git a/src/main/text-generation/source-control-command-line-budget.ts b/src/main/text-generation/source-control-command-line-budget.ts new file mode 100644 index 00000000000..c9c37bf4f12 --- /dev/null +++ b/src/main/text-generation/source-control-command-line-budget.ts @@ -0,0 +1,26 @@ +import type { CommitMessagePlan } from '../../shared/commit-message-plan' +import { commandLineLength, MAX_COMMAND_LINE_CHARS } from '../../shared/windows-command-line-budget' + +// Linux caps each argv entry in bytes (128KiB on 4KiB pages), before total argv capacity; reserve headroom for single-prompt arguments. +const LINUX_SINGLE_ARGUMENT_BYTE_BUDGET = 120 * 1024 +/** Whether the plan's argv plus reserve fits this host's limit: UTF-16 units on Windows, bytes per argument on Linux. */ +export function fitsLocalCommandLine(plan: CommitMessagePlan, reserve = 0): boolean { + if (process.platform === 'win32') { + return commandLineLength([plan.binary, ...plan.args]) + reserve <= MAX_COMMAND_LINE_CHARS + } + if (process.platform === 'linux') { + return plan.args.every( + (arg) => Buffer.byteLength(arg, 'utf8') + reserve <= LINUX_SINGLE_ARGUMENT_BYTE_BUDGET + ) + } + return true +} + +export function argumentBudgetFailure(plan: CommitMessagePlan): string | null { + if (fitsLocalCommandLine(plan)) { + return null + } + return process.platform === 'win32' + ? `${plan.label} prompt is too large for the Windows command line. Stage fewer changes and try again.` + : `${plan.label} prompt is too large to pass as a single command-line argument. Stage fewer changes and try again.` +} diff --git a/src/main/text-generation/source-control-local-generation.ts b/src/main/text-generation/source-control-local-generation.ts index 0005cabfd61..c5c01c4f4a5 100644 --- a/src/main/text-generation/source-control-local-generation.ts +++ b/src/main/text-generation/source-control-local-generation.ts @@ -1,3 +1,4 @@ +import { wslDistroForCommand } from '../../shared/wsl-paths' import { mergeCommandEnvironment } from '../../shared/command-environment' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import { @@ -27,12 +28,16 @@ export function runLocalPlanForAgent(input: { spawnAgent: SpawnSourceControlAgent shouldExecute?: () => boolean }): Promise { + const wslDistro = input.plan.promptFile + ? (wslDistroForCommand(input.target.cwd, input.target.wslDistro) ?? undefined) + : input.target.wslDistro const target = { ...input.target, + wslDistro, env: mergeCommandEnvironment( input.target.env, input.plan.env, - input.target.wslDistro ? 'linux' : process.platform + wslDistro ? 'linux' : process.platform ) } const start = (): LocalProcessExecution => { @@ -45,10 +50,10 @@ export function runLocalPlanForAgent(input: { return runLocalSourceControlPlan({ plan: input.plan, cwd: input.target.cwd, - env: input.target.wslDistro ? input.target.env : target.env, + env: wslDistro ? input.target.env : target.env, emptyResultName: input.emptyResultName, operation: input.operation, - wslDistro: input.target.wslDistro, + wslDistro, spawnAgent: input.spawnAgent }) } diff --git a/src/main/text-generation/source-control-local-process.ts b/src/main/text-generation/source-control-local-process.ts index 2063ce58091..aa2caa4bab1 100644 --- a/src/main/text-generation/source-control-local-process.ts +++ b/src/main/text-generation/source-control-local-process.ts @@ -1,9 +1,13 @@ +import { killSourceControlAgentProcess } from './source-control-agent-termination' +import { wslDistroForCommand } from '../../shared/wsl-paths' +import { prepareWslPrompt, stageLocalPromptFiles } from './source-control-prompt-files' +import { producePromptRecords } from './source-control-prompt-producer' +import { argumentBudgetFailure } from './source-control-command-line-budget' +export { fitsLocalCommandLine } from './source-control-command-line-budget' +import { removeTree } from '../../shared/windows-transient-lock-removal' import { WindowsCmdLineBreakError } from '@orca/process-host' -import type { CommitMessagePlan } from '../../shared/commit-message-plan' -import { commandLineLength, MAX_COMMAND_LINE_CHARS } from '../../shared/windows-command-line-budget' +import { WindowsCommandLineBudgetError } from '../../shared/windows-command-line-budget' import { supervisedProviderSpawnFailure } from '../provider-process/provider-spawn-failure-report' -import { stopSupervisedChildProcess } from '../provider-process/supervised-child-process-stop' -import { terminateWindowsProcessTree } from '../windows-process-tree-kill' import { finalizeFromAgentOutput, userFacingUnsafeWindowsBatchArgs @@ -21,77 +25,43 @@ import type { InternalTextGenerationResult, LocalProcessExecution, SpawnedSourceControlAgentProcess, - SpawnSourceControlAgent, - TextGenerationOperation + LocalSourceControlPlanInput } from './source-control-text-generation-types' -export const SOURCE_CONTROL_KILL_SITE = 'source-control-text-generation' +export * from './source-control-agent-termination' -export async function killSourceControlAgentProcess( - child: SpawnedSourceControlAgentProcess -): Promise { - const pid = child.pid - if (!pid) { - return - } - if (child.supervised) { - await stopSupervisedChildProcess(child, { site: SOURCE_CONTROL_KILL_SITE }) - return - } - if (process.platform === 'win32') { - // Tree-walk refusal must not release the home lock having killed nothing; root handle kill cannot hit a recycled PID. - await terminateWindowsProcessTree(pid, { site: SOURCE_CONTROL_KILL_SITE }) - } - try { - child.kill('SIGKILL') - } catch { - // The process may exit between the PID check and kill. - } -} - -// Linux caps each argv entry in bytes (128KiB on 4KiB pages), before total argv capacity; reserve headroom for single-prompt arguments. -const LINUX_SINGLE_ARGUMENT_BYTE_BUDGET = 120 * 1024 - -/** Whether the plan's argv plus reserve fits this host's limit: UTF-16 units on Windows, bytes per argument on Linux. */ -export function fitsLocalCommandLine(plan: CommitMessagePlan, reserve = 0): boolean { - if (process.platform === 'win32') { - return commandLineLength([plan.binary, ...plan.args]) + reserve <= MAX_COMMAND_LINE_CHARS - } - if (process.platform === 'linux') { - return plan.args.every( - (arg) => Buffer.byteLength(arg, 'utf8') + reserve <= LINUX_SINGLE_ARGUMENT_BYTE_BUDGET - ) - } - return true -} - -function argumentBudgetFailure(plan: CommitMessagePlan): string | null { - if (fitsLocalCommandLine(plan)) { - return null - } - return process.platform === 'win32' - ? `${plan.label} prompt is too large for the Windows command line. Stage fewer changes and try again.` - : `${plan.label} prompt is too large to pass as a single command-line argument. Stage fewer changes and try again.` -} - -export function runLocalSourceControlPlan(input: { - plan: CommitMessagePlan - cwd: string - env: NodeJS.ProcessEnv | undefined - emptyResultName: string - operation: TextGenerationOperation - wslDistro?: string - spawnAgent: SpawnSourceControlAgent -}): LocalProcessExecution { - const { plan, cwd, operation } = input +export function runLocalSourceControlPlan( + input: LocalSourceControlPlanInput +): LocalProcessExecution { + let { plan } = input + const { cwd, operation } = input + const distro = plan.promptFile ? wslDistroForCommand(cwd, input.wslDistro) : null + const guest = distro ? prepareWslPrompt(plan, distro) : undefined + let closed = false + let promptDirectory: string | undefined let markProcessClosed!: () => void const processClosed = new Promise((resolve) => { - markProcessClosed = resolve + markProcessClosed = () => { + closed = true + if (promptDirectory) { + const directory = promptDirectory + promptDirectory = undefined + void removeTree(directory).catch((error: unknown) => { + console.error('[commit-message] Failed to remove generation prompt:', error) + }) + } + resolve() + } }) const couldNotStart = `${plan.label} could not be started. Check the agent command in Settings and try again.` const result = new Promise((resolve) => { let child: SpawnedSourceControlAgentProcess try { + plan = + guest?.plan ?? + stageLocalPromptFiles(plan, (directory) => { + promptDirectory = directory + }) // Reject oversized argv before opaque E2BIG spawn failure so users can act on the prompt-size limit. const budgetFailure = argumentBudgetFailure(plan) if (budgetFailure) { @@ -105,12 +75,20 @@ export function runLocalSourceControlPlan(input: { cwd, env: input.env, commandEnv: plan.env, - wslDistro: input.wslDistro, + wslDistro: distro ?? input.wslDistro, + ...(guest ? { preparation: guest.preparation } : {}), stdinMode: 'pipe', useCwdForNative: true }) } catch (error) { markProcessClosed() + if (error instanceof WindowsCommandLineBudgetError) { + resolve({ + success: false, + error: `${plan.label} prompt is too large for the Windows command line. Stage fewer changes and try again.` + }) + return + } if (error instanceof WindowsCmdLineBreakError) { resolve({ success: false, @@ -123,6 +101,8 @@ export function runLocalSourceControlPlan(input: { return } + const stop = new AbortController() + const deadline = Date.now() + SOURCE_CONTROL_GENERATION_TIMEOUT_MS let stdout = '' let stderr = '' let stdoutBytes = 0 @@ -135,7 +115,10 @@ export function runLocalSourceControlPlan(input: { let terminal = false let terminationComplete: Promise | null = null let detachChildListeners = (): void => {} + const active = (): boolean => + !settled && !terminal && !stop.signal.aborted && Date.now() < deadline const startTermination = (): void => { + stop.abort() terminationComplete ??= killSourceControlAgentProcess(child).catch((error: unknown) => { console.error('[commit-message] Local generator termination failed:', error) }) @@ -150,6 +133,7 @@ export function runLocalSourceControlPlan(input: { return } settled = true + stop.abort() if (timer) { clearTimeout(timer) timer = null @@ -187,6 +171,9 @@ export function runLocalSourceControlPlan(input: { stdout += chunk.toString('utf-8') } const onStderrData = (chunk: Buffer): void => { + if (settled) { + return + } stderrBytes += chunk.byteLength if (stderrBytes > MAX_SOURCE_CONTROL_AGENT_OUTPUT_BYTES) { outputLimitExceeded = true @@ -216,6 +203,7 @@ export function runLocalSourceControlPlan(input: { } const onClose = (code: number | null): void => { terminal = true + stop.abort() child.off?.('close', onClose) markClosedAfterTermination() if (canceledByUser) { @@ -255,12 +243,47 @@ export function runLocalSourceControlPlan(input: { }) ) } + child.stdin?.on?.('error', (error: Error) => { + if (active()) { + startTermination() + onError(error) + } + }) + child.stdin?.on?.('close', () => { + if (guest && active() && !child.stdin?.writableEnded) { + startTermination() + onError(new Error('Generation input closed.')) + } + }) child.stdout?.on('data', onStdoutData) - child.stderr?.on('data', onStderrData) + if (guest) { + child.stderr?.on('data', (chunk: Buffer) => { + const proved = guest.observe(chunk, onStderrData) + if (proved) { + promptDirectory = guest.cleanupPath + if (closed) { + markProcessClosed() + } + if (active() && child.stdin) { + void producePromptRecords(child.stdin, guest.records(), stop.signal, active).catch( + (error: unknown) => { + if (active()) { + startTermination() + onError(error instanceof Error ? error : new Error(String(error))) + } + } + ) + } + } + }) + } else { + child.stderr?.on('data', onStderrData) + } child.on('error', onError) child.on('close', onClose) child.on('exit', () => { terminal = true + stop.abort() markClosedAfterTermination() }) detachChildListeners = () => { @@ -268,7 +291,11 @@ export function runLocalSourceControlPlan(input: { child.stderr?.off?.('data', onStderrData) } try { - child.stdin?.end(plan.stdinPayload ?? undefined) + if (!guest) { + child.stdin?.end(plan.stdinPayload ?? undefined) + } else if (!child.stdin) { + throw new Error('Generation input is unavailable.') + } } catch (error) { startTermination() onError(error instanceof Error ? error : new Error(String(error))) diff --git a/src/main/text-generation/source-control-omp-prompt-file.test.ts b/src/main/text-generation/source-control-omp-prompt-file.test.ts new file mode 100644 index 00000000000..e81dfdad725 --- /dev/null +++ b/src/main/text-generation/source-control-omp-prompt-file.test.ts @@ -0,0 +1,440 @@ +import { spawnProcess } from '@orca/process-host' +import * as agentSpecs from '../../shared/commit-message-agent-spec' +import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' +import type { SpawnSourceControlAgent } from './source-control-text-generation-types' +import { + generateCommitMessage, + generatePullRequestFields, + generateBranchName +} from './source-control-text-generation-requests' +import { generateConversationName } from './conversation-name-generation-request' +import { generateJiraIssueSummaryFromContext } from './jira-issue-summary-text-generation' + +import { existsSync, mkdtempSync, readFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { planCommitMessageGeneration } from '../../shared/commit-message-plan' +import { cancelLocalGeneration } from './source-control-generation-lanes' +import { SOURCE_CONTROL_GENERATION_TIMEOUT_MS } from './source-control-generation-limits' +import { runLocalSourceControlPlan } from './source-control-local-process' +import { + createMockDiscoveryChild, + withPlatform +} from './commit-message-text-generation-test-harness' + +vi.mock('../windows-process-tree-kill', () => ({ + terminateWindowsProcessTree: vi.fn(async () => true) +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, rm: vi.fn(actual.rm) } +}) + +const actualFs = await vi.importActual('node:fs/promises') + +afterEach(() => vi.useRealTimers()) + +// OMP 17.0.5 recorded stdin-only execution: exit 0, empty stdout/stderr, no submitted prompt. +const recordedPrompt = `Name this chat from the user's first request. +Write a short, specific title of at most 48 characters. +Output only the title, with no heading, quotes, or explanation. + +First request: +Trace a lost lighthouse signal. + +Additional instructions: +Return Lighthouse Signal Trace. +` + +it.each(['success', 'failure', 'cancel', 'timeout', 'spawn failure'] as const)( + 'delivers the recorded OMP prompt without stdin and removes the owned file after %s', + async (outcome) => { + vi.useFakeTimers() + const prompt = recordedPrompt + 'x'.repeat(200_000) + const planned = planCommitMessageGeneration({ agentId: 'omp', model: 'default' }, prompt) + if (!planned.ok) { + throw new Error(planned.error) + } + const child = createMockDiscoveryChild() + let promptPath = '' + const execution = withPlatform('win32', () => + runLocalSourceControlPlan({ + plan: planned.plan, + cwd: '/omp-file-test', + env: undefined, + operation: 'conversation-name', + emptyResultName: 'chat name', + spawnAgent: ({ args }) => { + promptPath = args[planned.plan.promptFile?.argIndex ?? -1]?.slice(1) ?? '' + expect(new TextDecoder().decode(readFileSync(promptPath))).toBe(prompt) + expect(args.join(' ').length).toBeLessThan(1000) + if (outcome === 'spawn failure') { + throw new Error('recorded spawn failure') + } + return child + } + }) + ) + if (outcome === 'success') { + // Like the recorded binary, this child never reads stdin; only the explicit file submits input. + child.stdout.emit('data', Buffer.from('Lighthouse Signal Trace')) + } else if (outcome === 'cancel') { + cancelLocalGeneration('conversation-name', '/omp-file-test') + } else if (outcome === 'timeout') { + await vi.advanceTimersByTimeAsync(SOURCE_CONTROL_GENERATION_TIMEOUT_MS) + } + if (outcome !== 'spawn failure') { + expect(child.stdin.end).toHaveBeenCalledWith(undefined) + expect(existsSync(promptPath)).toBe(true) + child.emit('close', outcome === 'failure' ? 1 : 0) + } + const result = await execution.result + await execution.processClosed + expect(result.success).toBe(outcome === 'success') + await vi.waitFor(() => expect(existsSync(dirname(promptPath))).toBe(false)) + } +) + +// Pinned OMP17 cli/file-processor.ts:16,49-54,119-122: per-file cap, ordered text decoding. +const attachmentReader = String.raw` +const fs = require('node:fs'); +const args = process.argv.slice(1); +const paths = args.filter(arg => arg.startsWith('@')).map(arg => arg.slice(1)); +const chunks = paths.map(path => fs.readFileSync(path)); +const isImage = bytes => ['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString()) || + (bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP'); +const fragments = chunks.map(bytes => bytes.length > 5 * 1024 * 1024 + ? '(skipped: too large)' : isImage(bytes) ? '(image instead of text)' : new TextDecoder().decode(bytes)); +let stdin = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { stdin += chunk; }); +process.stdin.on('end', () => { + fs.writeFileSync(process.env.OMP_RECEIPT, JSON.stringify({ + paths, sizes: chunks.map(bytes => bytes.length), content: paths.length ? fragments.join('') : stdin, stdin, + message: fragments.map((text, index) => '\n' + text + '\n\n').join('') + args.at(-1) + })); + process.stdout.write(process.env.OMP_OUTPUT); + if (process.env.OMP_EXIT === '7') process.stderr.write('Fixture provider unavailable'); + process.exitCode = Number(process.env.OMP_EXIT); +}); +` + +const contractParams = { + agentId: 'omp', + model: 'generated/model', + thinkingLevel: 'low', + agentCommandOverride: 'OMP_RECIPE_ENV=kept node /configured/prefix/omp.js --model prefix/model', + agentArgs: '--model recipe/model --thinking high -- recipe-message', + commandInputTemplate: 'Saved recipe instruction\n{basePrompt}' +} as const +const contractTarget = { kind: 'local', cwd: tmpdir() } as const +const commitContext = { + branch: 'feature', + stagedSummary: 'signal.ts | 1 +', + stagedPatch: '+ Trace lighthouse signal' +} + +type Consumer = { + label: string + run( + params: ResolvedSourceControlAiGenerationParams, + spawnAgent: SpawnSourceControlAgent + ): Promise + output: string + expected: object +} +const consumers: Consumer[] = [ + { + label: 'commit', + run: (params, spawnAgent) => + generateCommitMessage({ context: commitContext, params, target: contractTarget, spawnAgent }), + output: 'Trace lighthouse signal.\n\nPreserve request bytes\n', + expected: { message: 'Trace lighthouse signal\n\nPreserve request bytes' } + }, + { + label: 'PR fields', + run: (params, spawnAgent) => + generatePullRequestFields({ + context: { + branch: 'feature', + base: 'main', + branchChangedByPreparation: true, + currentTitle: '', + currentBody: '', + currentDraft: false, + commitSummary: 'Trace lighthouse signal', + changeSummary: 'signal.ts', + patch: '+ Trace lighthouse signal' + }, + params, + target: contractTarget, + spawnAgent + }), + output: JSON.stringify({ + base: 'main', + title: 'Trace lighthouse signal.', + body: 'Preserve request bytes\n', + draft: true + }), + expected: { + fields: { + base: 'main', + title: 'Trace lighthouse signal', + body: 'Preserve request bytes', + draft: true + }, + branchChangedByPreparation: true + } + }, + { + label: 'branch slug / folder title', + run: (params, spawnAgent) => + generateBranchName({ + context: { firstPrompt: 'Trace lighthouse signal' }, + params, + target: contractTarget, + spawnAgent + }), + output: 'Trace Lighthouse Signal!', + expected: { slug: 'trace-lighthouse-signal' } + }, + { + label: 'conversation name', + run: (params, spawnAgent) => + generateConversationName({ + context: { firstPrompt: 'Trace lighthouse signal' }, + params, + target: contractTarget, + spawnAgent + }), + output: '# Title: "Trace lighthouse signal"\nextra commentary', + expected: { name: 'Trace lighthouse signal' } + }, + { + label: 'Jira summary', + run: (params, spawnAgent) => + generateJiraIssueSummaryFromContext( + { description: 'Trace lighthouse signal' }, + params, + contractTarget, + spawnAgent + ), + output: '"Trace lighthouse signal"\nextra commentary', + expected: { summary: 'Trace lighthouse signal' } + } +] + +type Receipt = { paths: string[]; sizes: number[]; content: string; stdin: string; message: string } +async function withReader( + output: string, + fail: boolean, + run: (spawnAgent: SpawnSourceControlAgent) => Promise +) { + const directory = mkdtempSync(join(tmpdir(), 'orca-omp-contract-')) + const receiptPath = join(directory, 'receipt.json') + try { + const result = await run((input) => { + expect(input.binary).toBe('node') + expect(input.args.slice(0, 1)).toEqual(['/configured/prefix/omp.js']) + expect(input.args).toContain('recipe/model') + expect(input.args).not.toContain('prefix/model') + expect(input.args).not.toContain('generated/model') + expect(input.args).toContain('high') + expect(input.args).not.toContain('low') + expect(input.args.slice(-2)).toEqual(['--', 'recipe-message']) + expect(input.commandEnv).toEqual({ OMP_RECIPE_ENV: 'kept' }) + return spawnProcess({ + program: process.execPath, + args: ['-e', attachmentReader, '--', ...input.args], + cwd: input.cwd, + env: { + ...process.env, + OMP_RECEIPT: receiptPath, + OMP_OUTPUT: output, + OMP_EXIT: fail ? '7' : '0' + }, + stdio: 'pipe' + }) + }) + const receipt: Receipt = JSON.parse(readFileSync(receiptPath, 'utf8')) + await vi.waitFor(() => { + for (const path of receipt.paths) { + expect(existsSync(dirname(path))).toBe(false) + } + }) + return { result, receipt } + } finally { + await rm(directory, { recursive: true, force: true }) + } +} + +it.each(consumers)( + 'preserves $label output and failure classification across stdin/file transport', + async (consumer) => { + const spec = agentSpecs.getCommitMessageAgentSpec('omp') + if (!spec) { + throw new Error('Missing OMP spec') + } + for (const fail of [false, true]) { + const delivered = await withReader(consumer.output, fail, (spawn) => + consumer.run(contractParams, spawn) + ) + expect(delivered.receipt.paths).toHaveLength(1) + expect(delivered.receipt.stdin).toBe('') + expect(delivered.receipt.message.endsWith('recipe-message')).toBe(true) + expect(delivered.receipt.content).toContain('Trace lighthouse signal') + expect(delivered.receipt.content.includes('Saved recipe instruction')).toBe( + consumer.label !== 'Jira summary' + ) + if (fail) { + expect(delivered.result).toMatchObject({ + success: false, + error: expect.stringContaining('OMP CLI command failed with code 7') + }) + } else { + expect(delivered.result).toMatchObject({ + success: true, + agentLabel: 'OMP', + ...consumer.expected + }) + } + const legacy = vi.spyOn(agentSpecs, 'getCommitMessageAgentSpec').mockReturnValue({ + ...spec, + promptDelivery: 'stdin', + buildArgs: (params) => spec.buildArgs(params).slice(0, -1) + }) + try { + const previous = await withReader(consumer.output, fail, (spawn) => + consumer.run(contractParams, spawn) + ) + expect(previous.receipt.paths).toHaveLength(0) + expect(previous.receipt.content).toBe(delivered.receipt.content) + expect(previous.result).toEqual(delivered.result) + } finally { + legacy.mockRestore() + } + } + } +) + +it.each(['below', 'at', 'above', 'multibyte', 'BOM run', 'GIF', 'RIFF', 'internal magic'] as const)( + 'delivers complete text recipe content: %s', + async (boundary) => { + const cap = 5 * 1024 * 1024 + const payloadCap = cap - 3 + const patches = { + below: () => 'x'.repeat(payloadCap - 1), + at: () => 'x'.repeat(payloadCap), + above: () => 'x'.repeat(cap + 1), + multibyte: () => + `${'x'.repeat(payloadCap - 1)}🌊界é${'界'.repeat(Math.ceil(cap / 3))}\uFEFFEND`, + 'BOM run': () => `${'\uFEFF'.repeat(Math.ceil(cap / 3))}Preserve every BOM`, + GIF: () => 'GIF89a is the header this change handles. Preserve this as text.', + RIFF: () => 'RIFFxxxxWEBP is the signature this change handles. Preserve this as text.', + 'internal magic': () => `${'x'.repeat(payloadCap)}GIF89a preserve this boundary as text.` + } + const patch = patches[boundary]() + const { result, receipt } = await withReader( + 'Preserve the complete request', + false, + (spawnAgent) => + generateCommitMessage({ + context: { ...commitContext, stagedPatch: patch }, + params: { ...contractParams, commandInputTemplate: '{stagedPatch}' }, + target: contractTarget, + spawnAgent + }) + ) + expect(result).toMatchObject({ success: true, message: 'Preserve the complete request' }) + expect(receipt.content).toBe(patch) + expect(receipt.sizes.every((size) => size <= cap)).toBe(true) + expect(receipt.paths).toHaveLength( + boundary === 'multibyte' + ? 3 + : ['above', 'BOM run', 'internal magic'].includes(boundary) + ? 2 + : 1 + ) + if (boundary === 'at') { + expect(receipt.sizes).toEqual([cap]) + } + } +) + +it.each(['success', 'spawn failure', 'permanent failure'] as const)( + 'settles independently of bounded async cleanup after %s', + async (outcome) => { + const originalPlatform = process.platform + const rmMock = vi.mocked(rm) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + const locked = Object.assign(new Error('EPERM: prompt handle held'), { code: 'EPERM' }) + let directory = '' + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + rmMock.mockClear() + rmMock.mockImplementationOnce(async () => { + throw locked + }) + if (outcome === 'permanent failure') { + rmMock.mockRejectedValue(locked) + } + try { + const planned = planCommitMessageGeneration( + { agentId: 'omp', model: 'default' }, + recordedPrompt + ) + if (!planned.ok) { + throw new Error(planned.error) + } + const child = createMockDiscoveryChild() + const execution = runLocalSourceControlPlan({ + plan: planned.plan, + cwd: '/omp-cleanup', + env: undefined, + operation: 'conversation-name', + emptyResultName: 'chat name', + spawnAgent: ({ args }) => { + directory = dirname(args[planned.plan.promptFile?.argIndex ?? -1].slice(1)) + if (outcome === 'spawn failure') { + throw new Error('Fixture spawn failure') + } + return child + } + }) + if (outcome !== 'spawn failure') { + child.stdout.emit('data', Buffer.from('Lighthouse Signal Trace')) + child.emit('close', 0) + } + expect((await execution.result).success).toBe(outcome !== 'spawn failure') + await execution.processClosed + expect(rmMock).toHaveBeenCalledTimes(1) + expect(existsSync(directory)).toBe(true) + if (outcome === 'permanent failure') { + await vi.waitFor( + () => + expect(errors).toHaveBeenCalledWith( + '[commit-message] Failed to remove generation prompt:', + locked + ), + { timeout: 5000 } + ) + expect(rmMock).toHaveBeenCalledTimes(5) + expect(errors).toHaveBeenCalledTimes(1) + expect(existsSync(directory)).toBe(true) + } else { + await vi.waitFor(() => expect(existsSync(directory)).toBe(false), { timeout: 2000 }) + expect(rmMock).toHaveBeenCalledTimes(2) + } + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + rmMock.mockImplementation(actualFs.rm) + if (directory) { + await actualFs.rm(directory, { recursive: true, force: true }) + } + errors.mockRestore() + } + } +) diff --git a/src/main/text-generation/source-control-prompt-files.ts b/src/main/text-generation/source-control-prompt-files.ts new file mode 100644 index 00000000000..eb3f9e4677c --- /dev/null +++ b/src/main/text-generation/source-control-prompt-files.ts @@ -0,0 +1,140 @@ +import { randomUUID } from 'node:crypto' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getUtf8ChunkEndIndex } from '../../shared/utf8-byte-limits' +import { encodePosixPrintfRecords } from '../../shared/posix-printf-encoding' +import { toWindowsWslUncPath } from '../../shared/wsl-paths' +import type { CommitMessagePlan } from '../../shared/commit-message-plan' + +// Reserve the transport signature under the native text attachment cap. +const PAYLOAD_BYTES = 5 * 1024 * 1024 - 3 + +export function* promptFileContents(content: string): Generator { + for (let start = 0; start < content.length;) { + const end = getUtf8ChunkEndIndex(content, start, PAYLOAD_BYTES) + yield `\uFEFF${content.slice(start, end)}` + start = end + } +} + +export function stageLocalPromptFiles( + plan: CommitMessagePlan, + own: (directory: string) => void +): CommitMessagePlan { + if (!plan.promptFile) { + return plan + } + const directory = mkdtempSync(join(tmpdir(), 'orca-generation-')) + own(directory) + const attachments: string[] = [] + for (const content of promptFileContents(plan.promptFile.content)) { + const path = join(directory, `prompt-${attachments.length + 1}.txt`) + writeFileSync(path, content, { mode: 0o600 }) + attachments.push(`@${path}`) + } + const args = [...plan.args] + args.splice(plan.promptFile.argIndex, 1, ...attachments) + return { ...plan, args } +} + +// Preparation precedes profiles; only a proved empty allocation may roll back before ACK. +export const WSL_PROMPT_PREPARATION = ` +nonce=$1; count=$2; shift 2 +dir=/tmp/orca-generation-$nonce +old_umask=$(umask) +umask 077 +mkdir "$dir" || exit 1 +trap 'rmdir "$dir" 2>/dev/null' EXIT +printf '\\nORCA_GENERATION_READY:%s\\n' "$nonce" >&2 || exit 1 +IFS= read -r record && [ "$record" = "A$nonce" ] || exit 1 +trap - EXIT +index=1 +while [ "$index" -le "$count" ]; do + file=$dir/prompt-$index.txt + : > "$file" || exit 1 + while :; do + IFS= read -r record || exit 1 + case "$record" in + D*) printf '%b' "\${record#D}" >> "$file" || exit 1 ;; + *) [ "$record" = "F$nonce:$index" ] || exit 1; break ;; + esac + done + index=$((index + 1)) +done +IFS= read -r record && [ "$record" = "X$nonce" ] || exit 1 +umask "$old_umask" +exec "$@" void): boolean { + let firstProof = false + let passthrough = 0 + for (let index = 0; index < chunk.length; index++) { + const byte = chunk[index] + if (oversized) { + const end = chunk.indexOf(10, index) + if (end === -1) { + diagnostic?.(chunk.subarray(passthrough)) + return firstProof + } + diagnostic?.(chunk.subarray(passthrough, end + 1)) + index = end + oversized = false + } else if (byte === 10) { + if (partial === proof) { + if (!proved) { + proved = true + firstProof = true + } + } else { + diagnostic?.(Buffer.from(`${partial}\n`, 'latin1')) + } + partial = '' + } else if (partial.length < proof.length && byte === proof.charCodeAt(partial.length)) { + partial += String.fromCharCode(byte) + } else { + if (partial) { + diagnostic?.(Buffer.from(partial, 'latin1')) + } + partial = '' + oversized = true + passthrough = index + index-- + } + if (!oversized) { + passthrough = index + 1 + } + } + return firstProof + }, + *records(): Generator { + yield `A${nonce}\n` + let index = 0 + for (const content of promptFileContents(plan.promptFile?.content ?? '')) { + for (const encoded of encodePosixPrintfRecords(content)) { + yield `D${encoded}\n` + } + yield `F${nonce}:${++index}\n` + } + yield `X${nonce}\n` + } + } +} diff --git a/src/main/text-generation/source-control-prompt-producer.ts b/src/main/text-generation/source-control-prompt-producer.ts new file mode 100644 index 00000000000..26a8a637061 --- /dev/null +++ b/src/main/text-generation/source-control-prompt-producer.ts @@ -0,0 +1,48 @@ +import { setImmediate as yieldToEvents } from 'node:timers/promises' +import type { Writable } from 'node:stream' + +/** One bounded record at a time; a stopped attempt also releases an outstanding drain wait. */ +export async function producePromptRecords( + stdin: Writable, + records: Iterable, + signal: AbortSignal, + active: () => boolean +): Promise { + let batch = 0 + for (const record of records) { + if (!active()) { + return + } + if (!stdin.write(record)) { + await new Promise((resolve) => { + const done = (): void => { + stdin.off('drain', done) + stdin.off('error', done) + stdin.off('close', done) + signal.removeEventListener('abort', done) + resolve() + } + stdin.once('drain', done) + stdin.once('error', done) + stdin.once('close', done) + signal.addEventListener('abort', done, { once: true }) + if (!active() || stdin.destroyed || stdin.writableEnded) { + done() + } + }) + if (!active() || stdin.destroyed || stdin.writableEnded) { + return + } + } + if (++batch === 16) { + batch = 0 + await yieldToEvents() + if (!active()) { + return + } + } + } + if (active()) { + stdin.end() + } +} diff --git a/src/main/text-generation/source-control-text-generation-types.ts b/src/main/text-generation/source-control-text-generation-types.ts index 6c93c15f901..4608bcd2ff0 100644 --- a/src/main/text-generation/source-control-text-generation-types.ts +++ b/src/main/text-generation/source-control-text-generation-types.ts @@ -108,6 +108,7 @@ export type SourceControlAgentSpawnInput = { wslDistro?: string // WSL applies these in the guest; native callers already merge them into env. commandEnv?: Record + preparation?: { script: string; args: string[] } stdinMode: 'ignore' | 'pipe' useCwdForNative: boolean } @@ -115,3 +116,13 @@ export type SourceControlAgentSpawnInput = { export type SpawnSourceControlAgent = ( input: SourceControlAgentSpawnInput ) => SpawnedSourceControlAgentProcess + +export type LocalSourceControlPlanInput = { + plan: CommitMessagePlan + cwd: string + env: NodeJS.ProcessEnv | undefined + emptyResultName: string + operation: TextGenerationOperation + wslDistro?: string + spawnAgent: SpawnSourceControlAgent +} diff --git a/src/main/text-generation/source-control-wsl-launch.test.ts b/src/main/text-generation/source-control-wsl-launch.test.ts new file mode 100644 index 00000000000..4e999c9b6f2 --- /dev/null +++ b/src/main/text-generation/source-control-wsl-launch.test.ts @@ -0,0 +1,302 @@ +import { spawnProcess } from '@orca/process-host' +import type * as ChildProcess from 'node:child_process' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, vi, afterEach } from 'vitest' +import { quotePosixShell } from '../../shared/shell-argument-quoting' +import { commandLineLength, MAX_COMMAND_LINE_CHARS } from '../../shared/windows-command-line-budget' +import { prepareWslPrompt } from './source-control-prompt-files' +import { producePromptRecords } from './source-control-prompt-producer' +import { planCommitMessageGeneration } from '../../shared/commit-message-plan' +import { + withPlatform, + createMockDiscoveryChild +} from './commit-message-text-generation-test-harness' +import { spawnSourceControlAgent } from './source-control-agent-launch' +import { runLocalPlanForAgent } from './source-control-local-generation' +import { resolveCommand } from '../git/command-runner/wsl-command-resolution' + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) +vi.mock('node:child_process', async (original) => { + const actual = await original() + spawnMock.mockImplementation(actual.spawn) + return { ...actual, spawn: spawnMock } +}) +vi.mock('../wsl-background-distro-gate', () => ({ noteWslDistroLaunched: vi.fn() })) +const nativeSpawn = (await vi.importActual('node:child_process')).spawn +afterEach(() => { + spawnMock.mockReset().mockImplementation(nativeSpawn) +}) +const repo = '\\\\wsl.localhost\\Actual\\home\\dev\\repo' +function planned(agentId: 'omp' | 'codex' = 'omp') { + const result = planCommitMessageGeneration( + { + agentId, + model: 'default', + agentCommandOverride: 'lower=recipe LOWER=UPPER omp --model prefix/model', + agentArgs: '--thinking high --mode text @user.txt -- recipe-message' + }, + 'request' + ) + if (!result.ok) { + throw new Error(result.error) + } + return result.plan +} + +it.each([undefined, 'Wrong', 'Actual'])( + 'routes UNC before hint %s with Linux assignments retained for OMP only', + async (hint) => { + const request = planned() + const child = createMockDiscoveryChild() + let guestPath = '' + const result = withPlatform('win32', () => + runLocalPlanForAgent({ + agentId: 'omp', + plan: request, + target: { + kind: 'local', + cwd: repo, + wslDistro: hint, + env: { lower: 'base', LOWER: 'BASE' } + }, + operation: 'conversation-name', + emptyResultName: 'name', + spawnAgent: (input) => { + expect(input.wslDistro).toBe('Actual') + expect(input.env).toEqual({ lower: 'base', LOWER: 'BASE' }) + expect(input.commandEnv).toEqual({ lower: 'recipe', LOWER: 'UPPER' }) + expect(input.preparation).toBeDefined() + guestPath = input.args.find((arg) => arg.startsWith('@/tmp/')) ?? '' + queueMicrotask(() => { + child.stdout.emit('data', Buffer.from('Useful name')) + child.emit('close', 0) + }) + return child + } + }) + ) + expect(await result).toMatchObject({ success: true }) + expect(guestPath).toContain('@/tmp/orca-generation-') + } +) + +it('preserves ordinary hinted WSL and unrelated provider no-hint native routing', async () => { + for (const agentId of ['omp', 'codex'] as const) { + const request = planned(agentId) + const child = createMockDiscoveryChild() + const result = withPlatform('win32', () => + runLocalPlanForAgent({ + agentId: 'custom', + plan: request, + target: { + kind: 'local', + cwd: agentId === 'omp' ? 'C:\\repo' : repo, + ...(agentId === 'omp' ? { wslDistro: 'Hint' } : {}) + }, + operation: 'conversation-name', + emptyResultName: 'name', + spawnAgent: (input) => { + expect(input.wslDistro).toBe(agentId === 'omp' ? 'Hint' : undefined) + expect(!!input.preparation).toBe(agentId === 'omp') + queueMicrotask(() => { + child.stdout.emit('data', Buffer.from('Useful name')) + child.emit('close', 0) + }) + return child + } + }) + ) + expect(await result).toMatchObject({ success: true }) + } +}) + +it('launches the original resolved wrapper once, preserving prefix, recipe, translation and assignment timing', () => { + const request = prepareWslPrompt(planned(), 'Actual') + spawnMock.mockReturnValueOnce(createMockDiscoveryChild()) + withPlatform('win32', () => { + const assignments = ['lower=recipe', 'LOWER=UPPER'] + const original = resolveCommand( + '/usr/bin/env', + [...assignments, planned().binary, ...request.plan.args], + repo, + 'Wrong', + { useWslLoginShell: true } + ) + spawnSourceControlAgent({ + binary: planned().binary, + args: request.plan.args, + cwd: repo, + commandEnv: planned().env, + wslDistro: 'Actual', + preparation: request.preparation, + stdinMode: 'pipe', + useCwdForNative: true + }) + const [binary, args] = spawnMock.mock.calls.at(-1)! + expect(binary).toBe('wsl.exe') + expect(args.slice(0, 3)).toEqual(['-d', 'Actual', '--exec']) + expect(args.slice(3, 7)).toEqual([ + '/bin/sh', + '-c', + request.preparation.script, + 'orca-generation' + ]) + expect(args.slice(9)).toEqual(original.args.slice(3)) + expect(original.args.at(-1)).toContain('/home/dev/repo') + expect(original.args.at(-1)).toContain('lower=recipe') + expect(args.join(' ')).toContain('@user.txt') + }) +}) + +it('checks the exact composed Windows argv at the inclusive boundary before spawning', () => { + const request = prepareWslPrompt(planned(), 'Actual') + const launch = (padding: number) => + withPlatform('win32', () => + spawnSourceControlAgent({ + binary: 'omp', + args: [...request.plan.args, 'x'.repeat(padding)], + cwd: repo, + wslDistro: 'Actual', + preparation: request.preparation, + stdinMode: 'pipe', + useCwdForNative: true + }) + ) + spawnMock.mockReturnValue(createMockDiscoveryChild()) + launch(0) + const [binary, args] = spawnMock.mock.calls.at(-1)! + const slack = MAX_COMMAND_LINE_CHARS - commandLineLength([binary, ...args]) + const padding = Math.floor(slack / 3) + launch(padding) + const [atBinary, atArgs] = spawnMock.mock.calls.at(-1)! + expect(commandLineLength([atBinary, ...atArgs])).toBeLessThanOrEqual(MAX_COMMAND_LINE_CHARS) + expect(commandLineLength([atBinary, ...atArgs])).toBeGreaterThan(MAX_COMMAND_LINE_CHARS - 3) + const count = spawnMock.mock.calls.length + expect(() => launch(padding + 1)).toThrow() + expect(spawnMock).toHaveBeenCalledTimes(count) +}) + +it.each([false, true])( + 'prepares before hostile profiles and preserves login invocation/cwd/env (assigned=%s)', + async (assigned) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca-c3-profiles-'))) + const repoPath = join(directory, 'repo') + const startup = join(directory, 'startup') + const changed = join(directory, 'profile-cwd') + const reportPath = join(directory, 'report.json') + if (!assigned) { + mkdirSync(repoPath) + } + mkdirSync(startup) + mkdirSync(changed) + const shell = join(directory, 'bash') + const profile = join(directory, 'rc') + writeFileSync( + shell, + `#!/bin/sh\nexec /bin/bash --noprofile --rcfile ${quotePosixShell(profile)} -ic "$2"\n`, + { mode: 0o700 } + ) + writeFileSync( + join(directory, 'getent'), + `#!/bin/sh\nprintf '%s\\n' ${quotePosixShell(`user:x:1:1:User:${directory}:${shell}`)}\n`, + { mode: 0o700 } + ) + writeFileSync( + join(directory, '.profile'), + `export PATH=${quotePosixShell(directory)}:"$PATH"\n` + ) + writeFileSync( + profile, + ` +export STARTUP_CWD="$PWD" +export PROFILE_LOWER="$lower" +if IFS= read -r stolen; then export STOLEN=yes; else export STOLEN=no; fi +alias mkdir='mkdir -pv' +alias printf='printf CORRUPTED' +set -C +command mkdir -p ${quotePosixShell(repoPath)} +cd ${quotePosixShell(changed)} +omp() { ${quotePosixShell(process.execPath)} ${quotePosixShell(join(directory, 'reader.cjs'))} "$@"; } +` + ) + writeFileSync( + join(directory, 'reader.cjs'), + String.raw` +const fs=require('node:fs'); const path=process.argv.find(arg=>arg.startsWith('@/tmp/')).slice(1); +fs.writeFileSync(process.env.REPORT, JSON.stringify({startup:process.env.STARTUP_CWD,cwd:process.cwd(),stolen:process.env.STOLEN,profileLower:process.env.PROFILE_LOWER,lower:process.env.lower,content:new TextDecoder().decode(fs.readFileSync(path)),stdin:fs.readFileSync(0).length,mask:process.umask()})); +` + ) + const request = prepareWslPrompt(planned(), 'Actual') + // Use the actual resolved native cwd and original wrapper, without starting a guest on this Mac. + const original = withPlatform('win32', () => + resolveCommand( + assigned ? '/usr/bin/env' : 'omp', + assigned + ? [ + 'lower=recipe', + 'LOWER=UPPER', + process.execPath, + join(directory, 'reader.cjs'), + ...request.plan.args + ] + : request.plan.args, + `\\\\wsl.localhost\\Actual${repoPath.replaceAll('/', '\\')}`, + undefined, + { useWslLoginShell: true } + ) + ) + const child = spawnProcess({ + program: '/bin/sh', + args: [ + '-c', + request.preparation.script, + 'fixture', + ...request.preparation.args, + ...original.args.slice(3) + ], + cwd: startup, + env: { ...process.env, HOME: directory, SHELL: shell, REPORT: reportPath, lower: 'base' }, + stdio: 'pipe' + }) + child.stdin.on('error', () => {}) + const stop = new AbortController() + const closed = new Promise((resolve, reject) => { + child.on('close', resolve) + child.on('error', reject) + }) + child.stderr.on('data', (chunk) => { + if (request.observe(chunk)) { + void producePromptRecords( + child.stdin, + request.records(), + stop.signal, + () => !stop.signal.aborted + ) + } + }) + try { + expect(await closed).toBe(0) + const receipt = JSON.parse(readFileSync(reportPath, 'utf8')) + expect(receipt).toMatchObject({ + startup, + cwd: repoPath, + stolen: 'no', + profileLower: 'base', + lower: assigned ? 'recipe' : 'base', + content: 'request', + stdin: 0 + }) + expect(receipt.mask).toBe(process.umask()) + } finally { + stop.abort() + await rm(`/tmp/orca-generation-${request.preparation.args[0]}`, { + recursive: true, + force: true + }) + await rm(directory, { recursive: true, force: true }) + } + } +) diff --git a/src/main/text-generation/source-control-wsl-prompt.test.ts b/src/main/text-generation/source-control-wsl-prompt.test.ts new file mode 100644 index 00000000000..cef0f83ca79 --- /dev/null +++ b/src/main/text-generation/source-control-wsl-prompt.test.ts @@ -0,0 +1,532 @@ +import { spawnProcess } from '@orca/process-host' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { join } from 'node:path' +import { Writable } from 'node:stream' +import { afterEach, expect, it, vi } from 'vitest' +import { planCommitMessageGeneration } from '../../shared/commit-message-plan' +import { prepareWslPrompt } from './source-control-prompt-files' +import { producePromptRecords } from './source-control-prompt-producer' +import { runLocalSourceControlPlan } from './source-control-local-process' +import { cancelLocalGeneration } from './source-control-generation-lanes' +import { + MAX_SOURCE_CONTROL_AGENT_OUTPUT_BYTES, + SOURCE_CONTROL_GENERATION_TIMEOUT_MS +} from './source-control-generation-limits' +import { + withPlatform, + createMockDiscoveryChild +} from './commit-message-text-generation-test-harness' +import type { SourceControlAgentSpawnInput } from './source-control-text-generation-types' +import type * as Removal from '../../shared/windows-transient-lock-removal' + +const { removeTreeMock } = vi.hoisted(() => ({ removeTreeMock: vi.fn(async () => {}) })) +vi.mock('../../shared/windows-transient-lock-removal', async (original) => ({ + ...(await original()), + removeTree: removeTreeMock +})) +vi.mock('../windows-process-tree-kill', () => ({ + terminateWindowsProcessTree: vi.fn(async () => true) +})) +vi.mock('../wsl-background-distro-gate', () => ({ noteWslDistroLaunched: vi.fn() })) + +afterEach(() => { + vi.useRealTimers() + removeTreeMock.mockReset() + removeTreeMock.mockResolvedValue(undefined) +}) + +const cwd = '\\\\wsl.localhost\\Actual\\home\\dev\\repo' +function plan(content = 'sensitive request') { + const planned = planCommitMessageGeneration({ agentId: 'omp', model: 'default' }, content) + if (!planned.ok) { + throw new Error(planned.error) + } + return planned.plan +} +function start(child = createMockDiscoveryChild(), content?: string) { + let launch: SourceControlAgentSpawnInput | undefined + const execution = withPlatform('win32', () => + runLocalSourceControlPlan({ + plan: plan(content), + cwd, + wslDistro: 'Wrong', + env: undefined, + operation: 'conversation-name', + emptyResultName: 'name', + spawnAgent: (input) => { + launch = input + return child + } + }) + ) + const nonce = launch?.preparation?.args[0] + if (!nonce) { + throw new Error('Expected preparation') + } + return { child, launch, nonce, execution, proof: `ORCA_GENERATION_READY:${nonce}\n` } +} +function close(child: ReturnType, code = 0) { + child.stdout.emit('data', Buffer.from('Lighthouse Signal Trace')) + child.emit('exit', code) + child.emit('close', code) +} + +it('waits for fragmented exact proof, streams once and cleans only the actual guest after terminal', async () => { + const { child, launch, nonce, execution, proof } = start() + const writes: string[] = [] + child.stdin.on('data', (chunk) => writes.push(chunk.toString())) + expect(launch?.wslDistro).toBe('Actual') + expect(launch?.args).toContain(`@/tmp/orca-generation-${nonce}/prompt-1.txt`) + expect(child.stdin.end).not.toHaveBeenCalled() + child.stderr.emit('data', Buffer.from(`${proof.trim()}WRONG\n${'x'.repeat(100_000)}\n`)) + child.stderr.emit('data', Buffer.from(proof.slice(0, 17))) + expect(writes).toEqual([]) + child.stderr.emit('data', Buffer.from(proof.slice(17))) + child.stderr.emit('data', Buffer.from(proof)) + expect(writes.join('')).toBe(`A${nonce}\nDsensitive request\nF${nonce}:1\nX${nonce}\n`) + expect(removeTreeMock).not.toHaveBeenCalled() + close(child) + expect(await execution.result).toMatchObject({ success: true }) + await execution.processClosed + expect(removeTreeMock).toHaveBeenCalledExactlyOnceWith( + `\\\\wsl.localhost\\Actual\\tmp\\orca-generation-${nonce}` + ) + child.stderr.emit('data', Buffer.from(proof)) + expect(removeTreeMock).toHaveBeenCalledTimes(1) +}) + +it.each(['cancel', 'deadline', 'exit', 'overflow', 'stdin-error', 'stdin-close'] as const)( + 'stops pending input on %s, and late proof only cleans without reopening result', + async (route) => { + vi.useFakeTimers() + const { child, execution, proof } = start() + const writes = vi.spyOn(child.stdin, 'write') + if (route === 'cancel') { + cancelLocalGeneration('conversation-name', cwd) + } + if (route === 'deadline') { + await vi.advanceTimersByTimeAsync(SOURCE_CONTROL_GENERATION_TIMEOUT_MS) + } + if (route === 'exit') { + close(child) + } + if (route === 'overflow') { + child.stdout.emit('data', Buffer.alloc(MAX_SOURCE_CONTROL_AGENT_OUTPUT_BYTES + 1)) + } + if (route === 'stdin-error') { + child.stdin.emit('error', new Error('EPIPE')) + } + if (route === 'stdin-close') { + child.stdin.emit('close') + } + child.stderr.emit('data', Buffer.from(proof)) + expect(writes).not.toHaveBeenCalled() + close(child) + await execution.result + await execution.processClosed + expect(removeTreeMock).toHaveBeenCalledTimes(1) + child.stdin.emit('error', new Error('late EPIPE')) + } +) + +it.each(['cancel', 'deadline', 'exit', 'error', 'close', 'overflow'] as const)( + 'releases a never-draining producer on %s without final authorization', + async (route) => { + vi.useFakeTimers() + const { child, execution, proof, nonce } = start(undefined, 'x'.repeat(50_000)) + const write = vi.spyOn(child.stdin, 'write').mockReturnValue(false) + child.stderr.emit('data', Buffer.from(proof)) + expect(write).toHaveBeenCalledExactlyOnceWith(`A${nonce}\n`) + if (route === 'cancel') { + cancelLocalGeneration('conversation-name', cwd) + } + if (route === 'deadline') { + await vi.advanceTimersByTimeAsync(SOURCE_CONTROL_GENERATION_TIMEOUT_MS) + } + if (route === 'exit') { + child.emit('exit', 1) + } + if (route === 'error') { + child.stdin.emit('error', new Error('EPIPE')) + } + if (route === 'close') { + child.stdin.emit('close') + } + if (route === 'overflow') { + child.stderr.emit('data', Buffer.alloc(MAX_SOURCE_CONTROL_AGENT_OUTPUT_BYTES + 1)) + } + await Promise.resolve() + child.stdin.emit('drain') + close(child, 1) + await execution.result + await execution.processClosed + expect(write).toHaveBeenCalledTimes(1) + expect(child.stdin.listenerCount('drain')).toBe(0) + expect(removeTreeMock).toHaveBeenCalledTimes(1) + } +) + +it.each(['missing-proof', 'spawn', 'no-pid', 'missing-stdin'] as const)( + 'settles %s without invented ownership', + async (route) => { + vi.useFakeTimers() + const child = createMockDiscoveryChild() + if (route === 'missing-stdin') { + Object.defineProperty(child, 'stdin', { value: null }) + } + if (route === 'no-pid') { + Object.defineProperty(child, 'pid', { value: undefined }) + } + const execution = withPlatform('win32', () => + runLocalSourceControlPlan({ + plan: plan(), + cwd, + env: undefined, + operation: 'conversation-name', + emptyResultName: 'name', + spawnAgent: () => { + if (route === 'spawn') { + throw new Error('spawn denied') + } + return child + } + }) + ) + if (route === 'no-pid') { + child.emit('error', Object.assign(new Error('not found'), { code: 'ENOENT' })) + } + if (route === 'missing-proof') { + await vi.advanceTimersByTimeAsync(SOURCE_CONTROL_GENERATION_TIMEOUT_MS) + } + if (route !== 'spawn' && route !== 'no-pid') { + close(child, 1) + } + expect(await execution.result).toMatchObject({ success: false }) + await execution.processClosed + expect(removeTreeMock).not.toHaveBeenCalled() + } +) + +it.each(['pending', 'rejected'] as const)( + 'never gates either promise on %s cleanup, even proof after processClosed', + async (outcome) => { + removeTreeMock.mockImplementation(() => + outcome === 'pending' ? new Promise(() => {}) : Promise.reject(new Error('share unavailable')) + ) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const { child, execution, proof } = start() + close(child) + expect(await execution.result).toMatchObject({ success: true }) + await execution.processClosed + child.stderr.emit('data', Buffer.from(proof)) + child.stderr.emit('data', Buffer.from(proof)) + expect(removeTreeMock).toHaveBeenCalledTimes(1) + expect(child.stdin.end).not.toHaveBeenCalled() + await Promise.resolve() + } finally { + log.mockRestore() + } + } +) + +const tricky = `${Array.from({ length: 128 }, (_, i) => String.fromCharCode(i)).join('')}\\c\\012\r\n'!%$\`🌊界\ud800` + +async function shellFixture( + content: string, + controls?: (records: string[], nonce: string) => string[], + setup?: (directory: string) => void, + onProof?: (directory: string) => void +) { + const guest = prepareWslPrompt(plan(content), 'Actual') + const [nonce] = guest.preparation.args + const directory = `/tmp/orca-generation-${nonce}` + setup?.(directory) + const child = spawnProcess({ + program: '/bin/sh', + args: [ + '-c', + guest.preparation.script, + 'fixture', + ...guest.preparation.args, + process.execPath, + '-e', + String.raw` +const fs = require('node:fs'); +const paths = process.argv.slice(1).map(arg => arg.slice(1)); +const files = paths.map(path => fs.readFileSync(path)); +process.stdout.write(JSON.stringify({ content: Buffer.concat(files.map(file => Buffer.from(new TextDecoder().decode(file)))).toString('base64'), + sizes: files.map(file => file.length), modes: paths.map(path => fs.statSync(path).mode & 511), directory: fs.statSync(process.env.FIXTURE_DIRECTORY).mode & 511, + eof: require('node:fs').readFileSync(0).length })); +`, + '--', + ...guest.plan.args.filter((arg) => arg.startsWith('@')) + ], + stdio: 'pipe', + env: { ...process.env, FIXTURE_DIRECTORY: directory } + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (bytes) => { + stdout += bytes + }) + const finished = new Promise((resolve, reject) => { + child.once('close', resolve) + child.once('error', reject) + }) + child.stdin.on('error', () => {}) + child.stderr.on('data', (chunk) => { + stderr += chunk + if (guest.observe(chunk)) { + onProof?.(directory) + const records = [...guest.records()] + child.stdin.end((controls ? controls(records, nonce) : records).join('')) + } + }) + try { + const code = await finished + return { + code, + stdout, + stderr, + directory, + existed: existsSync(directory), + sentinel: existsSync(join(directory, 'sentinel')) + ? readFileSync(join(directory, 'sentinel'), 'utf8') + : null + } + } finally { + await rm(directory, { recursive: true, force: true }) + } +} + +it.each(['', tricky.repeat(12), `${'x'.repeat(5 * 1024 * 1024 - 4)}🌊GIF89a${tricky}`])( + 'materializes exact native text with private modes across encoding and file boundaries', + async (content) => { + const result = await shellFixture(content) + expect(result.code).toBe(0) + const receipt = JSON.parse(result.stdout) + expect(Buffer.from(receipt.content, 'base64').toString()).toBe(Buffer.from(content).toString()) + expect(receipt.modes.every((mode: number) => mode === 0o600)).toBe(true) + expect(receipt.directory).toBe(0o700) + expect(receipt.eof).toBe(0) + expect(receipt.sizes.every((size: number) => size <= 5 * 1024 * 1024)).toBe(true) + expect(result.existed).toBe(true) + } +) + +it.each([ + 'collision', + 'partial-data', + 'wrong-end', + 'missing-end', + 'partial-execute', + 'extra-data', + 'wrong-ack' +] as const)('does not execute on %s, and never adopts/deletes a collision', async (route) => { + const result = await shellFixture( + 'private data', + (records, nonce) => { + if (route === 'partial-data') { + return [`A${nonce}\nDpartial`] + } + if (route === 'wrong-end') { + return records.map((record) => (record.startsWith('F') ? `F${nonce}:2\n` : record)) + } + if (route === 'missing-end') { + return records.filter((record) => !record.startsWith('F')) + } + if (route === 'partial-execute') { + return records.map((record) => (record.startsWith('X') ? record.trim() : record)) + } + if (route === 'extra-data') { + return [...records.slice(0, -1), 'Dextra\n', ...records.slice(-1)] + } + if (route === 'wrong-ack') { + return ['Ano\n'] + } + return records + }, + (directory) => { + if (route === 'collision') { + mkdirSync(directory) + writeFileSync(join(directory, 'sentinel'), 'untouched') + } + } + ) + expect(result.code).not.toBe(0) + expect(result.stdout).toBe('') + if (route === 'collision') { + expect(result.stderr).not.toContain('ORCA_GENERATION_READY:') + expect(result.sentinel).toBe('untouched') + } + if (route === 'wrong-ack') { + expect(result.existed).toBe(false) + } +}) + +it.each([false, true])( + 'bounds real producer work at N and 10N (backpressure=%s)', + async (backpressure) => { + const results: number[] = [] + for (const size of [20_000, 200_000]) { + const guest = prepareWslPrompt( + plan(tricky.repeat(Math.ceil(size / tricky.length)).slice(0, size)), + 'Actual' + ) + let writes = 0 + let maxRecord = 0 + let pending = 0 + let maxPending = 0 + let maxQueued = 0 + const stream = new Writable({ + highWaterMark: 2048, + write(chunk, _encoding, callback) { + writes++ + maxRecord = Math.max(maxRecord, chunk.length) + maxPending = Math.max(maxPending, ++pending) + const complete = () => { + maxQueued = Math.max(maxQueued, stream.writableLength) + pending-- + callback() + } + if (backpressure) { + setImmediate(complete) + } else { + complete() + } + } + }) + let yieldedAt = 0 + setImmediate(() => { + yieldedAt = writes + }) + const finished = new Promise((resolve) => stream.once('finish', resolve)) + await producePromptRecords(stream, guest.records(), new AbortController().signal, () => true) + await finished + expect(maxQueued).toBeLessThanOrEqual(2048 + 1026) + expect(yieldedAt).toBeGreaterThan(0) + expect(yieldedAt).toBeLessThan(writes) + expect(maxRecord).toBeLessThanOrEqual(1026) + expect(maxPending).toBe(1) + results.push(writes) + } + expect(results[1]).toBeLessThanOrEqual(results[0] * 10 + 10) + expect(results[1]).toBeGreaterThan(results[0] * 8) + } +) + +it('fails a real post-proof storage redirect without running a partial request', async () => { + const result = await shellFixture('private data', undefined, undefined, (directory) => + mkdirSync(join(directory, 'prompt-1.txt')) + ) + expect(result.code).not.toBe(0) + expect(result.stdout).toBe('') + expect(result.existed).toBe(true) +}) + +it.each([ + { boundary: 'data', stopping: 'cancel' }, + { boundary: 'final', stopping: 'cancel' }, + { boundary: 'data', stopping: 'deadline' }, + { boundary: 'final', stopping: 'deadline' } +] as const)( + 'does not authorize after $stopping at $boundary write continuation', + async ({ boundary, stopping }) => { + vi.useFakeTimers() + const { child, execution, proof, nonce } = start() + const writes: string[] = [] + vi.spyOn(child.stdin, 'write').mockImplementation((chunk) => { + const record = String(chunk) + writes.push(record) + if (record.startsWith(boundary === 'data' ? 'D' : 'F')) { + if (stopping === 'cancel') { + cancelLocalGeneration('conversation-name', cwd) + } else { + vi.advanceTimersByTime(SOURCE_CONTROL_GENERATION_TIMEOUT_MS) + } + } + return true + }) + child.stderr.emit('data', Buffer.from(proof)) + close(child) + expect(await execution.result).toMatchObject( + stopping === 'cancel' + ? { success: false, canceled: true } + : { success: false, error: 'Generation timed out after 60s.' } + ) + await execution.processClosed + expect(writes).not.toContain(`X${nonce}\n`) + } +) + +it.each(['success', 'nonzero', 'empty', 'storage', 'pipe-close'] as const)( + 'runs the production attempt through real private shell preparation and %s', + async (outcome) => { + let directory = '' + let actualPipeError = false + const execution = withPlatform('win32', () => + runLocalSourceControlPlan({ + plan: plan(tricky.repeat(40)), + cwd, + env: undefined, + operation: 'conversation-name', + emptyResultName: 'name', + spawnAgent: (input) => { + const preparation = input.preparation + if (!preparation) { + throw new Error('Missing preparation') + } + directory = `/tmp/orca-generation-${preparation.args[0]}` + const argv = + outcome === 'pipe-close' + ? [ + '-c', + `exec 0<&-; printf 'ORCA_GENERATION_READY:%s\\n' "$1" >&2; sleep 0.1`, + 'fixture', + preparation.args[0] + ] + : [ + '-c', + preparation.script, + 'fixture', + ...preparation.args, + '/bin/sh', + '-c', + outcome === 'empty' + ? ':' + : `printf '%s' 'Useful name'; exit ${outcome === 'nonzero' ? 7 : 0}` + ] + const child = withPlatform('darwin', () => + spawnProcess({ program: '/bin/sh', args: argv, stdio: 'pipe' }) + ) + child.stdin.on('error', (error) => { + actualPipeError ||= 'code' in error && error.code === 'EPIPE' + }) + if (outcome === 'storage') { + child.stderr.once('data', () => mkdirSync(join(directory, 'prompt-1.txt'))) + } + return child + } + }) + ) + try { + const result = await execution.result + await execution.processClosed + expect(result.success).toBe(outcome === 'success') + expect(removeTreeMock).toHaveBeenCalledTimes(1) + if (outcome === 'pipe-close') { + expect(actualPipeError).toBe(true) + } + if (outcome === 'success') { + const file = readFileSync(join(directory, 'prompt-1.txt')) + expect(new TextDecoder().decode(file)).toBe(Buffer.from(tricky.repeat(40)).toString()) + } + } finally { + if (directory) { + await rm(directory, { recursive: true, force: true }) + } + } + } +) diff --git a/src/renderer/src/lib/source-control-generation-plan.test.ts b/src/renderer/src/lib/source-control-generation-plan.test.ts index 99f5c86ea02..49cb5aae7dc 100644 --- a/src/renderer/src/lib/source-control-generation-plan.test.ts +++ b/src/renderer/src/lib/source-control-generation-plan.test.ts @@ -15,6 +15,16 @@ describe('planSourceControlCommitMessageGeneration', () => { ).toEqual({ ok: false, error: 'Command input is empty.' }) }) + it('describes OMP file delivery in dry-run plans', () => { + const result = planSourceControlCommitMessageGeneration({ + agentId: 'omp', + model: 'gpt-5.5', + thinkingLevel: 'low' + }) + + expect(result.ok && result.delivery).toContain('temporary file attachment') + }) + it('expands linkedIssue when validating commit and pull-request recipes', () => { for (const actionId of ['commitMessage', 'pullRequest'] as const) { // Why: `{prompt}` puts the rendered template in argv, so commandLabel shows it. diff --git a/src/renderer/src/lib/source-control-generation-plan.ts b/src/renderer/src/lib/source-control-generation-plan.ts index ce65a25fcad..d487bf26a08 100644 --- a/src/renderer/src/lib/source-control-generation-plan.ts +++ b/src/renderer/src/lib/source-control-generation-plan.ts @@ -75,8 +75,9 @@ export function planSourceControlTextGeneration( if (!planned.ok) { return { ok: false, error: planned.error } } - const delivery = - planned.plan.stdinPayload === null + const delivery = planned.plan.promptFile + ? 'Prompt is delivered as a temporary file attachment.' + : planned.plan.stdinPayload === null ? 'Prompt is delivered as command arguments.' : 'Prompt is piped to the agent over stdin.' return { diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index e9f3031f4df..51b16d598ac 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -44,8 +44,8 @@ export type CommitMessageAgentSpec = { id: TuiAgent label: string binary: string - /** Where the prompt is delivered. Large diffs go via stdin to avoid argv limits. */ - promptDelivery: 'argv' | 'stdin' + /** Transport for the prompt; stdin and file delivery avoid argv limits. */ + promptDelivery: 'argv' | 'stdin' | 'file' buildArgs: (params: { prompt: string model: string @@ -94,8 +94,8 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial [ + promptDelivery: 'file', + buildArgs: ({ prompt, model, thinkingLevel }) => [ '--print', '--no-session', '--no-tools', @@ -105,7 +105,8 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial { }) } ) - it('plans stdin delivery with tools and extensions disabled', () => { + it('plans file delivery with tools and extensions disabled', () => { const spec = getCommitMessageAgentSpec('omp') - expect(spec?.promptDelivery).toBe('stdin') + expect(spec?.promptDelivery).toBe('file') if (!spec) { throw new Error('Missing OMP spec') } - const args = spec.buildArgs({ prompt: 'large diff', model: 'default' }) + const args = spec.buildArgs({ prompt: '@{prompt-file}', model: 'default' }) expect(args).toEqual([ '--print', '--no-session', @@ -33,11 +33,23 @@ describe('OMP Source Control AI', () => { '--no-skills', '--no-rules', '--mode', - 'text' + 'text', + '@{prompt-file}' ]) expect( - spec?.buildArgs({ prompt: '', model: 'provider/exact-model', thinkingLevel: 'low' }) - ).toEqual([...args, '--model', 'provider/exact-model', '--thinking', 'low']) + spec?.buildArgs({ + prompt: '@{prompt-file}', + model: 'provider/exact-model', + thinkingLevel: 'low' + }) + ).toEqual([ + ...args.slice(0, -1), + '--model', + 'provider/exact-model', + '--thinking', + 'low', + '@{prompt-file}' + ]) }) it('discovers provider-qualified models through the existing OMP JSON parser', () => { const discovery = getCommitMessageAgentSpec('omp')?.modelDiscovery @@ -58,8 +70,10 @@ describe('OMP Source Control AI', () => { { agentId: 'omp', model: 'provider/model', + thinkingLevel: 'medium', backslash, - agentArgs: '--model provider/override' + agentCommandOverride: 'OMP_ENV=value omp --model provider/prefix --thinking high', + agentArgs: '--model provider/override --thinking low --verbose --' }, prompt ) @@ -67,7 +81,16 @@ describe('OMP Source Control AI', () => { if (!result.ok) { throw new Error(result.error) } - expect(result.plan.stdinPayload).toBe(prompt) + expect(result.plan.stdinPayload).toBeNull() + expect(result.plan.promptFile).toEqual({ + content: prompt, + argIndex: result.plan.args.indexOf('@{prompt-file}') + }) + expect(result.plan.env).toEqual({ OMP_ENV: 'value' }) + expect(result.plan.args.slice(-3)).toEqual(['@{prompt-file}', '--verbose', '--']) + expect(result.plan.args.filter((arg) => arg === '--thinking')).toHaveLength(1) + expect(result.plan.args).toContain('low') + expect(result.plan.args).not.toContain('high') expect(result.plan.args).not.toContain(prompt) expect(result.plan.args.filter((arg) => arg === '--model')).toHaveLength(1) expect(result.plan.args).toContain('provider/override') diff --git a/src/shared/commit-message-plan.ts b/src/shared/commit-message-plan.ts index ce7ec29e6a8..3bf6ac2f6d1 100644 --- a/src/shared/commit-message-plan.ts +++ b/src/shared/commit-message-plan.ts @@ -36,6 +36,8 @@ export type CommitMessagePlan = { args: string[] /** Non-null when the prompt should be piped via stdin. */ stdinPayload: string | null + /** Execution-host-owned prompt attachment, materialized only when starting the process. */ + promptFile?: { content: string; argIndex: number } /** Human-readable label used in error prefixes (e.g. "Claude failed: ..."). */ label: string /** Leading command assignments, applied on the execution host. */ @@ -137,7 +139,7 @@ function applySingletonOptionOverrides(args: { function insertAdditionalAgentArgs(args: { baseArgs: string[] agentArgs: string[] - promptDelivery: 'argv' | 'stdin' + promptDelivery: 'argv' | 'stdin' | 'file' prompt: string }): string[] { if (!args.agentArgs.length) { @@ -221,7 +223,8 @@ export function planCommitMessageGeneration( } } - const argvPrompt = spec.promptDelivery === 'argv' ? prompt : '' + const argvPrompt = + spec.promptDelivery === 'file' ? '@{prompt-file}' : spec.promptDelivery === 'argv' ? prompt : '' const agentArgs = planAdditionalAgentArgs(input.agentArgs, input.backslash) if (!agentArgs.ok) { return agentArgs @@ -287,6 +290,14 @@ export function planCommitMessageGeneration( : spec.promptDelivery === 'stdin' ? prompt : null, + ...(spec.promptDelivery === 'file' + ? { + promptFile: { + content: prompt, + argIndex: merged.prefixArgs.length + merged.generatedArgs.length - 1 + } + } + : {}), label: spec.label, ...(formatOption?.value === 'json' ? { outputFormat: 'opencode-json' as const } : {}), ...(kimiFormatOption?.value === 'stream-json' diff --git a/src/shared/omp-source-control-discovery.spec.ts b/src/shared/omp-source-control-discovery.spec.ts index 432810914fc..1a6d54c9a40 100644 --- a/src/shared/omp-source-control-discovery.spec.ts +++ b/src/shared/omp-source-control-discovery.spec.ts @@ -64,7 +64,11 @@ for (const hostKey of ['local', 'ssh:omp-host']) { expect(resolved.value.params.model).toBe(selectedModel ?? 'default') const args = spec.buildArgs({ prompt: '', model: resolved.value.params.model }) if (selectedModel === 'provider/exact-model') { - expect(args.slice(-2)).toEqual(['--model', 'provider/exact-model']) + const modelIndex = args.indexOf('--model') + expect(args.slice(modelIndex, modelIndex + 2)).toEqual([ + '--model', + 'provider/exact-model' + ]) } else { expect(args).not.toContain('--model') } diff --git a/src/shared/posix-printf-encoding.ts b/src/shared/posix-printf-encoding.ts new file mode 100644 index 00000000000..ad1599aabde --- /dev/null +++ b/src/shared/posix-printf-encoding.ts @@ -0,0 +1,25 @@ +export const POSIX_PRINTF_RECORD_MAX_BYTES = 1_024 +const PRINTF_ESCAPED_BYTES = new Set([0x21, 0x27, 0x5c]) + +export function* encodePosixPrintfRecords(command: string): Generator { + let chunk = '' + let chunkBytes = 0 + for (const character of command) { + const codePoint = character.codePointAt(0)! + const isSafePrintableAscii = + codePoint >= 0x20 && codePoint <= 0x7e && !PRINTF_ESCAPED_BYTES.has(codePoint) + const encodedCharacter = + codePoint > 0x7f || isSafePrintableAscii + ? character + : `\\0${codePoint.toString(8).padStart(3, '0')}` + const encodedBytes = codePoint > 0x7f ? Buffer.byteLength(character) : encodedCharacter.length + if (chunkBytes + encodedBytes > POSIX_PRINTF_RECORD_MAX_BYTES) { + yield chunk + chunk = '' + chunkBytes = 0 + } + chunk += encodedCharacter + chunkBytes += encodedBytes + } + yield chunk +} diff --git a/src/shared/windows-command-line-budget.ts b/src/shared/windows-command-line-budget.ts index cac913e9260..c5d7a35e2af 100644 --- a/src/shared/windows-command-line-budget.ts +++ b/src/shared/windows-command-line-budget.ts @@ -46,3 +46,5 @@ function countEscapes(arg: string): number { } return count } + +export class WindowsCommandLineBudgetError extends Error {} diff --git a/src/shared/wsl-paths.ts b/src/shared/wsl-paths.ts index 4385d231233..c4dac7cc6a5 100644 --- a/src/shared/wsl-paths.ts +++ b/src/shared/wsl-paths.ts @@ -162,3 +162,11 @@ export function uncRouteKey(cwd: string): string | null { const server = cwd.slice(2).split(/[\\/]/, 1)[0] return `unc:${server.toLowerCase()}` } + +/** Match command routing: a native UNC cwd takes precedence over a distro hint. */ +export function wslDistroForCommand(cwd: string | undefined, override?: string): string | null { + if (process.platform !== 'win32') { + return null + } + return (cwd ? parseWslUncPath(cwd)?.distro : undefined) ?? override ?? null +}