From 8f063f356711fd7ece0eed8df83af85f5dde2677 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 12 Oct 2026 13:52:28 -0700 Subject: [PATCH] perf(copilot): run the Windows status hook as a direct .cmd, faster and UTF-8 safe (#27718) * perf(hooks): register direct .cmd hooks for Gemini, DSH, Kiro, Droid and Command Code on Windows On a cmd-safe profile path these agents now register the managed .cmd path itself instead of a `powershell.exe -EncodedCommand` launcher, so each hook event no longer pays a second PowerShell start-up on top of the runner's own shell. Profiles the bare path cannot carry (spaces, non-ASCII, UNC) keep the encoded launcher. Same pattern as Claude (#18875) and Muse (#26755). Refs #20913 * fix(kimi): register a cmd.exe-runnable hook on Windows Kimi runs hooks with asyncio.create_subprocess_shell, which is cmd.exe on Windows. Orca registered a POSIX `if [ -f ... ]` command there, so cmd failed with "-f was unexpected at this time" before reaching the script and, since Kimi fails open, no status event was ever sent. On win32 write a cmd+curl kimi-hook.cmd and register it the way the other direct .cmd agents do: the bare forward-slash path on a cmd-safe profile, the encoded launcher otherwise. The ownership matcher already covers every extension of the stem, so the old kimi-hook.sh entry is replaced on the next install. SSH remotes keep the POSIX script. Kimi joins the direct .cmd runner suite as a row run the way CPython's shell=True does, and every row now also runs from a profile with a space (the encoded launcher) and outside an Orca pane. * fix(copilot): run the Windows status hook as a direct curl .cmd Copilot runs a Windows hook as `pwsh|powershell -nop -nol -c `. Orca registered an encoded `powershell.exe -EncodedCommand` launcher there that started a second PowerShell to run copilot-hook.ps1, which posted with Invoke-WebRequest: two PowerShell starts per event (~850-900 ms median), and the .ps1 read stdin through the console code page, so non-ASCII prompt text arrived as "caf?? ...". On win32 write copilot-hook.cmd (answer `{}`, refresh the endpoint, env guard before stdin, curl form POST with hookEventName) and register its bare path with the event as its argument; non-cmd-safe profile paths keep the encoded launcher, which now sets ORCA_COPILOT_HOOK_EVENT for the .cmd. The managed matcher covers every extension of the stem, so old .ps1 entries are replaced on the next install while user hooks stay. SSH remotes keep the POSIX script. Copilot joins the direct .cmd runner suite as a row run under both PowerShells, checking the byte-exact payload and the posted hookEventName. Refs #20913 * refactor(hooks): one Windows batch-hook helper and one payload-delivery table - Add wrapWindowsBatchHookCommand (bare .cmd path, encoded launcher otherwise) and use it for Muse, Gemini, DSH, Kiro, Droid and Command Code. - Fold the five agents into windows-hook-payload-delivery.test.ts as rows run under each vendor's shell, from a plain and a spaced profile, with a stale encoded registration seeded per row; drop the separate runner suite and its CI list entries. - Replace the Windows-only per-agent registration tests with ones that stub process.platform, so they run on every CI OS. - Fix stale launcher, helper and DSH comments. Refs #20913 --------- Co-authored-by: Orca Worker --- src/main/agent-hooks/hook-stdin-contract.ts | 4 +- .../managed-hook-stdin-lifecycle.test.ts | 9 +- .../windows-hook-payload-delivery.test.ts | 119 ++++++++++++++++-- .../windows-powershell-hook-launcher.ts | 3 +- .../copilot-managed-hook-definitions.ts | 11 +- src/main/copilot/copilot-managed-script.ts | 57 ++++----- src/main/copilot/hook-service.test.ts | 117 +++++++++++++++-- .../native-chat/NativeChatCodeBlock.test.tsx | 7 +- 8 files changed, 257 insertions(+), 70 deletions(-) diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index ec32ef17d83..4892fc088a5 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -181,8 +181,8 @@ const WINDOWS_HOOK_ENVIRONMENT_VARS = [ // may abandon stdin rather than close it — a read-to-EOF then blocks forever and strands a // visible window per hook event. The Windows rule: a hook must check the Orca env before it // owns stdin, and exit without reading when the env is missing — the payload is discarded on -// that path anyway. This applies to .cmd, the copilot .ps1, and Git Bash hooks alike, -// and to the launchers that own stdin themselves when the managed script is missing. +// that path anyway. This applies to .cmd and Git Bash hooks alike, and to the +// PowerShell launchers that own stdin themselves when the managed script is missing. // POSIX hooks keep capture-first: their callers close stdin, and exiting mid-write there // surfaces as EPIPE the agent can see (#8110). export function buildWindowsHookEnvironmentGuardLines(): string[] { diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 81b174f2bc1..b581561918d 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -285,7 +285,7 @@ describe('Windows managed hook stdin structure', () => { (name) => name.endsWith('-hook.cmd') && !name.startsWith('antigravity-') ) mainBatchScripts.push('antigravity-hook.cmd') - expect(mainBatchScripts).toHaveLength(11) + expect(mainBatchScripts).toHaveLength(12) for (const fileName of mainBatchScripts) { const script = readFileSync(join(hooksDir, fileName), 'utf8') // Why: missing-env path must not touch more.com — hang class from #11549. @@ -330,13 +330,6 @@ describe('Windows managed hook stdin structure', () => { claude.indexOf('if not "%DEVIN_PROJECT_DIR%"=="" goto :orca_agent_hook_drain_stdin') ) - // Why (#11549 class): every Windows-local hook now guards before owning stdin — - // the caller may abandon the pipe, and the payload is discarded on this path anyway. - const copilot = readFileSync(join(hooksDir, 'copilot-hook.ps1'), 'utf8') - expect(copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')).toBeGreaterThan(-1) - expect(copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')).toBeLessThan( - copilot.indexOf('[Console]::In.ReadToEnd()') - ) // Why: the two encoded-PowerShell launchers own stdin themselves when the managed // script is missing, so the same guard has to precede their ReadToEnd — and the // fallback answer has to precede the guard, or a gate event outside a pane is diff --git a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts index 634837ce1c6..4a661accfb9 100644 --- a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts +++ b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts @@ -8,10 +8,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' import { createServer, type Server } from 'node:http' -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import type * as osModule from 'node:os' const { homedirMock } = vi.hoisted(() => ({ @@ -34,6 +34,7 @@ vi.mock('os', async (importOriginal) => { import { ClaudeHookService } from '../claude/hook-service' import { CommandCodeHookService } from '../command-code/hook-service' +import { CopilotHookService } from '../copilot/hook-service' import { DroidHookService } from '../droid/hook-service' import { DshHookService } from '../dsh/hook-service' import { GeminiHookService } from '../gemini/hook-service' @@ -57,7 +58,12 @@ const PAYLOAD = JSON.stringify({ transcript: 'y'.repeat(4096) }) -type HookPost = { payload: string | null; paneKey: string | null; token: string | null } +type HookPost = { + payload: string | null + paneKey: string | null + token: string | null + hookEventName: string | null +} async function startHookListener(): Promise<{ server: Server @@ -77,7 +83,8 @@ async function startHookListener(): Promise<{ posts.push({ payload: form.get('payload'), paneKey: form.get('paneKey'), - token: typeof token === 'string' ? token : null + token: typeof token === 'string' ? token : null, + hookEventName: form.get('hookEventName') }) res.writeHead(200, { 'content-type': 'application/json' }) res.end('{}') @@ -148,6 +155,26 @@ const POWERSHELL_PLAIN: Runner = { program: () => POWERSHELL, args: (command) => ['-NoProfile', '-Command', command] } +function findPwsh(): string | undefined { + const candidates = [ + join(process.env.ProgramFiles ?? 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join(process.env.LOCALAPPDATA ?? '', 'Microsoft', 'WindowsApps', 'pwsh.exe') + ] + // lstat: the Store install is an app-execution alias that existsSync cannot follow. + return candidates.find((candidate) => lstatSync(candidate, { throwIfNoEntry: false })) +} +// Copilot's ` -nop -nol -c `; pwsh only where it is installed. +const COPILOT_RUNNERS: Runner[] = [POWERSHELL, findPwsh()].flatMap((program) => + program + ? [ + { + name: `${basename(program)} (Copilot)`, + program: () => program, + args: (c: string) => ['-nop', '-nol', '-c', c] + } + ] + : [] +) // CPython's shell=True: `{ComSpec} /c "{command}"`, without `/d`. const CMD_PYTHON_SHELL: Runner = { name: 'cmd.exe (Python shell=True)', @@ -164,10 +191,10 @@ function readTomlEventCommands(path: string, event: string): string[] { ) } -type Entry = { command?: string; hooks?: { command?: string }[] } +type Entry = { command?: string; powershell?: string; hooks?: { command?: string }[] } const commandsOf = (entries: Entry[] | undefined): (string | undefined)[] => (entries ?? []).flatMap((entry) => - entry.hooks ? entry.hooks.map((h) => h.command) : entry.command + entry.hooks ? entry.hooks.map((h) => h.command) : (entry.command ?? entry.powershell) ) function readEvent(path: string, event: string): (string | undefined)[] { @@ -177,7 +204,7 @@ function readEvent(path: string, event: string): (string | undefined)[] { function writeHooks( path: string, hooks: Record, - extra: { name?: string } = {} + extra: { name?: string; version?: number } = {} ): void { mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, JSON.stringify({ ...extra, hooks })) @@ -193,6 +220,8 @@ type Provider = { /** Seeds the pre-#27717 encoded registration so install must replace it, not add to it. */ seedStale?: (home: string, stale: string) => void runners?: Runner[] + /** Event name the hook posts alongside the payload, when it posts one. */ + hookEventName?: string } const PROVIDERS: Provider[] = [ @@ -280,6 +309,22 @@ const PROVIDERS: Provider[] = [ commands: (home) => readTomlEventCommands(join(home, '.kimi-code', 'config.toml'), 'UserPromptSubmit'), runners: [CMD_PYTHON_SHELL] + }, + { + // One event stands in for all: each registration differs only in the event argument. + name: 'Copilot', + script: 'copilot-hook.cmd', + stdout: '{}', + install: () => new CopilotHookService().install(), + commands: (home) => readEvent(join(home, '.copilot', 'hooks', 'orca.json'), 'Stop'), + seedStale: (home, stale) => + writeHooks( + join(home, '.copilot', 'hooks', 'orca.json'), + { Stop: [{ powershell: stale }] }, + { version: 1 } + ), + runners: COPILOT_RUNNERS, + hookEventName: 'Stop' } ] @@ -343,7 +388,13 @@ describe.skipIf(process.platform !== 'win32')('Windows managed hook payload deli for (const name of ['USERPROFILE', 'HOME']) { vi.stubEnv(name, home) } - for (const name of ['XDG_CONFIG_HOME', 'KIRO_HOME', 'DSH_HOME', 'KIMI_CODE_HOME']) { + for (const name of [ + 'XDG_CONFIG_HOME', + 'KIRO_HOME', + 'DSH_HOME', + 'KIMI_CODE_HOME', + 'COPILOT_HOME' + ]) { vi.stubEnv(name, undefined) } seedCmdAutoRunTarget(home) @@ -397,7 +448,12 @@ describe.skipIf(process.platform !== 'win32')('Windows managed hook payload deli // Byte-exact, not "non-empty" — a pseudoconsole host delivers nothing, and a // code-page-translating launcher delivers a corrupted body that still looks posted. expect(listener.posts.slice(before), `${runner.name} posts`).toEqual([ - { payload: PAYLOAD, paneKey: PANE_KEY, token: HOOK_TOKEN } + { + payload: PAYLOAD, + paneKey: PANE_KEY, + token: HOOK_TOKEN, + hookEventName: provider.hookEventName ?? null + } ]) expect(result.stderr, `${runner.name} stderr`).toBe('') // Why: compat consumers gate the tool call on parseable stdout (#14818). @@ -413,4 +469,49 @@ describe.skipIf(process.platform !== 'win32')('Windows managed hook payload deli }, 60_000 ) + + // Copilot parses stdout on PreToolUse and PermissionRequest, so a printing AutoRun must not + // reach it. Only bites where HKCU AutoRun targets %USERPROFILE%\.cmd_aliases.cmd (the control + // below proves it did); elsewhere cmd.exe runs no AutoRun at all. + it('Copilot answers exactly {} when the cmd AutoRun prints', async () => { + root = mkdtempSync(join(tmpdir(), 'orca-hook-autorun-')) + const home = join(root, 'user') + mkdirSync(home) + homedirMock.mockReturnValue(home) + for (const name of ['USERPROFILE', 'HOME']) { + vi.stubEnv(name, home) + } + vi.stubEnv('COPILOT_HOME', undefined) + writeFileSync(join(home, '.cmd_aliases.cmd'), '@echo autorun-noise\r\n', 'utf8') + expect(new CopilotHookService().install().state).toBe('installed') + const [command] = readEvent(join(home, '.copilot', 'hooks', 'orca.json'), 'PreToolUse') + if (!command) { + throw new Error('Missing managed Copilot PreToolUse command') + } + const env = hookEnvironment({ USERPROFILE: home, HOME: home }) + + const autoRun = await runProcess({ program: CMD, args: ['/c', 'rem'], env, timeoutMs: 15_000 }) + for (const runner of COPILOT_RUNNERS) { + const result = await runProcess({ + program: runner.program(), + args: runner.args(command), + env, + input: PAYLOAD, + timeoutMs: 15_000 + }) + expect(result.code, `${runner.name} exit code`).toBe(0) + expect(result.stdout, `${runner.name} stdout`).toBe('{}\r\n') + } + if (autoRun.stdout.includes('autorun-noise')) { + const bare = command.replace(/^\S+\/cmd\.exe \/d \/c /, '') + const control = await runProcess({ + program: POWERSHELL, + args: ['-nop', '-nol', '-c', bare], + env, + input: PAYLOAD, + timeoutMs: 15_000 + }) + expect(control.stdout, 'without /d the AutoRun prints first').toContain('autorun-noise') + } + }, 60_000) }) diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.ts index b2e419834eb..9785d0cd694 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.ts @@ -58,8 +58,7 @@ const HOOK_PROGRESS_SILENCER = "$ProgressPreference='SilentlyContinue'; " * * Equivalent by construction: the switch sets the Process scope too, and both * lose to a Group Policy scope. `-EncodedCommand` itself is never policy-gated, - * so this always gets to run; it is what lets the managed `.ps1` hooks (Copilot) - * execute under a Restricted or AllSigned machine policy. + * so this always gets to run, even under a Restricted or AllSigned machine policy. * * try/catch as well as `-ErrorAction SilentlyContinue`: under a MachinePolicy or * UserPolicy GPO the cmdlet reports that the process scope did not take, and diff --git a/src/main/copilot/copilot-managed-hook-definitions.ts b/src/main/copilot/copilot-managed-hook-definitions.ts index 8b2695cc24a..5b981248733 100644 --- a/src/main/copilot/copilot-managed-hook-definitions.ts +++ b/src/main/copilot/copilot-managed-hook-definitions.ts @@ -3,6 +3,8 @@ import { wrapWindowsHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { getWindowsSystem32Path } from '../agent-hooks/windows-powershell-hook-launcher' // Why: Copilot's user-level hook files can use VS Code-compatible PascalCase // names, which match the event vocabulary already normalized by Orca's hook @@ -30,7 +32,14 @@ export function getManagedCommand(scriptPath: string, eventName: string): string if (process.platform !== 'win32') { return wrapPosixHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName }) } - return wrapWindowsHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName }) + // Why an argument, not the hook `env` map: every Copilot version runs the command text, so the + // event survives runners that ignore `env`, and each event keeps a distinct managed command. + const direct = wrapWindowsDirectCmdHookCommand(scriptPath) + // Why `/d`: Copilot runs the `powershell` field only under PowerShell, which starts a bare .cmd + // without `/d`, so a printing cmd AutoRun would land ahead of the `{}` Copilot parses. + return direct + ? `${getWindowsSystem32Path('cmd.exe')} /d /c ${direct} ${eventName}` + : wrapWindowsHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName }) } export function getManagedHookDefinition(command: string): HookDefinition { diff --git a/src/main/copilot/copilot-managed-script.ts b/src/main/copilot/copilot-managed-script.ts index 018b026c992..f5b8698b6ff 100644 --- a/src/main/copilot/copilot-managed-script.ts +++ b/src/main/copilot/copilot-managed-script.ts @@ -1,12 +1,16 @@ -import { getSharedManagedScriptPath } from '../agent-hooks/installer-utils' +import { + buildWindowsAgentHookPostCommand, + getSharedManagedScriptPath +} from '../agent-hooks/installer-utils' import { buildPosixHookPayloadCapture, buildPosixHookSpoolLines, - WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' export function getManagedScriptFileName(): string { - return process.platform === 'win32' ? 'copilot-hook.ps1' : 'copilot-hook.sh' + return process.platform === 'win32' ? 'copilot-hook.cmd' : 'copilot-hook.sh' } export function getManagedScriptPath(): string { @@ -16,39 +20,20 @@ export function getManagedScriptPath(): string { export function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ - "Write-Output '{}'", - // Why: endpoint.cmd is cmd syntax, not PowerShell. Parse its `set KEY=...` - // lines so surviving PTYs can refresh to the current Orca server. - 'if ($env:ORCA_AGENT_HOOK_ENDPOINT -and (Test-Path -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT)) {', - ' try {', - ' Get-Content -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT | ForEach-Object {', - " if ($_ -match '^set ([A-Za-z0-9_]+)=(.*)$') {", - " [Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process')", - ' }', - ' }', - ' } catch {}', - '}', - // Why (#11549 class): missing Orca context means a user-wide hook fired outside an - // Orca pane. ReadToEnd blocks forever if that caller abandons the pipe, so the guard - // must run before the hook owns stdin; the payload would be discarded anyway. - WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD, - '$inputData = [Console]::In.ReadToEnd()', - 'if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }', - 'try {', - ' $payload = $inputData | ConvertFrom-Json', - ' $body = @{', - ' paneKey = $env:ORCA_PANE_KEY', - ' launchToken = $env:ORCA_AGENT_LAUNCH_TOKEN', - ' tabId = $env:ORCA_TAB_ID', - ' worktreeId = $env:ORCA_WORKTREE_ID', - ' hookEventName = $env:ORCA_COPILOT_HOOK_EVENT', - ' env = $env:ORCA_AGENT_HOOK_ENV', - ' version = $env:ORCA_AGENT_HOOK_VERSION', - ' payload = $payload', - ' } | ConvertTo-Json -Depth 100', - " Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/copilot') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body -TimeoutSec 2 | Out-Null", - '} catch {}', - 'exit 0', + '@echo off', + 'setlocal', + // Why: Copilot consumes stdout for some hooks, so answer before any guard can exit. + 'echo {}', + // Why: the direct registration passes the event as %1; the encoded fallback sets the env var. + 'if not "%~1"=="" set "ORCA_COPILOT_HOOK_EVENT=%~1"', + // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookPostCommand('copilot', [ + ' --data-urlencode "hookEventName=%ORCA_COPILOT_HOOK_EVENT%" ^' + ]), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), '' ].join('\r\n') } diff --git a/src/main/copilot/hook-service.test.ts b/src/main/copilot/hook-service.test.ts index a9260460deb..a6c0e37d7fb 100644 --- a/src/main/copilot/hook-service.test.ts +++ b/src/main/copilot/hook-service.test.ts @@ -1,9 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' +import { readHooksJson, wrapWindowsHookCommand } from '../agent-hooks/installer-utils' +import { createAgentHookMemorySftp } from '../agent-hooks/agent-hook-memory-sftp.test-fixture' +import { COPILOT_EVENTS, getManagedCommand } from './copilot-managed-hook-definitions' +import { getManagedScript } from './copilot-managed-script' import { CopilotHookService } from './hook-service' let tmpDir: string @@ -24,6 +28,7 @@ beforeEach(() => { }) afterEach(() => { + vi.restoreAllMocks() if (originalCopilotHome === undefined) { delete process.env.COPILOT_HOME } else { @@ -88,13 +93,8 @@ describe('CopilotHookService', () => { expect(firstPromptHook.type).toBe('command') expect(firstPromptHook.timeoutSec).toBe(5) if (process.platform === 'win32') { - const powershell = firstPromptHook.powershell as string - expect(powershell).toContain('-EncodedCommand') - const encoded = powershell.match(/ -EncodedCommand (\S+)$/)?.[1] - const decoded = Buffer.from(encoded!, 'base64').toString('utf16le') - expect(decoded).toContain('agent-hooks') - expect(decoded).toContain('copilot-hook.ps1') - expect(decoded).toContain("$env:ORCA_COPILOT_HOOK_EVENT = 'UserPromptSubmit'") + const scriptPath = join(tmpDir, '.orca', 'agent-hooks', 'copilot-hook.cmd') + expect(firstPromptHook.powershell).toBe(getManagedCommand(scriptPath, 'UserPromptSubmit')) } else { expect(firstPromptHook.bash).toContain('if [ -f ') expect(firstPromptHook.bash).toContain('] && [ -x ') @@ -104,6 +104,107 @@ describe('CopilotHookService', () => { expect(existsSync(join(tmpDir, '.orca', 'agent-hooks', 'copilot-hook.sh'))).toBe( process.platform !== 'win32' ) + expect(existsSync(join(tmpDir, '.orca', 'agent-hooks', 'copilot-hook.cmd'))).toBe( + process.platform === 'win32' + ) + }) + + describe('on Windows', () => { + beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + }) + + it('runs the .cmd through cmd.exe /d with the event as its argument', () => { + vi.stubEnv('SystemRoot', 'C:\\Windows') + for (const eventName of COPILOT_EVENTS) { + expect( + getManagedCommand('C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.cmd', eventName) + ).toBe( + `C:/Windows/System32/cmd.exe /d /c C:/Users/alice/.orca/agent-hooks/copilot-hook.cmd ${eventName}` + ) + } + vi.unstubAllEnvs() + }) + + it('passes the event through the encoded launcher when the path is not cmd-safe', () => { + const scriptPath = 'C:\\Users\\Bob Smith\\.orca\\agent-hooks\\copilot-hook.cmd' + for (const eventName of COPILOT_EVENTS) { + expect(getManagedCommand(scriptPath, eventName)).toBe( + wrapWindowsHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName }) + ) + } + }) + + it('writes a cmd script that answers first and posts the event name with curl', () => { + const script = getManagedScript() + const lines = script.split('\r\n') + expect(lines.slice(0, 3)).toEqual(['@echo off', 'setlocal', 'echo {}']) + expect(script).toContain('if not "%~1"=="" set "ORCA_COPILOT_HOOK_EVENT=%~1"') + expect(script).toContain('"%SystemRoot%\\System32\\curl.exe" -sS -X POST') + expect(script).toContain('/hook/copilot') + expect(script).toContain('--data-urlencode "hookEventName=%ORCA_COPILOT_HOOK_EVENT%" ^') + expect(script).not.toMatch(/powershell|Invoke-WebRequest/i) + // #11549: the env guard must run before curl owns stdin. + expect(script.indexOf('if "%ORCA_PANE_KEY%"=="" exit /b 0')).toBeLessThan( + script.indexOf('payload@-') + ) + expect(script.indexOf('set "ORCA_COPILOT_HOOK_EVENT')).toBeLessThan( + script.indexOf('hookEventName=') + ) + }) + + it('replaces the legacy .ps1 registration on install and keeps user hooks', () => { + const legacyPath = join(tmpDir, '.orca', 'agent-hooks', 'copilot-hook.ps1') + const configPath = join(copilotHome, 'hooks', 'orca.json') + mkdirSync(join(copilotHome, 'hooks'), { recursive: true }) + const userHook = { type: 'command', powershell: 'Write-Output user-hook', timeoutSec: 3 } + const legacyHooks = Object.fromEntries( + COPILOT_EVENTS.map((eventName) => [ + eventName, + [ + { + type: 'command', + powershell: wrapWindowsHookCommand(legacyPath, { + ORCA_COPILOT_HOOK_EVENT: eventName + }), + timeoutSec: 5 + } + ] + ]) + ) + legacyHooks.UserPromptSubmit.unshift(userHook) + writeFileSync(configPath, JSON.stringify({ version: 1, hooks: legacyHooks }, null, 2)) + const service = new CopilotHookService() + expect(service.getStatus().state).toBe('partial') + + expect(service.install().state).toBe('installed') + const hooks = readHooksJson(configPath)?.hooks ?? {} + const scriptPath = join(tmpDir, '.orca', 'agent-hooks', 'copilot-hook.cmd') + for (const eventName of COPILOT_EVENTS) { + const managed = { + type: 'command', + powershell: getManagedCommand(scriptPath, eventName), + timeoutSec: 5 + } + expect(hooks[eventName]).toEqual( + eventName === 'UserPromptSubmit' ? [userHook, managed] : [managed] + ) + } + expect(JSON.stringify(hooks)).not.toContain('copilot-hook.ps1') + expect(existsSync(scriptPath)).toBe(true) + }) + + it('keeps installing the POSIX script on SSH hosts', async () => { + const remote = createAgentHookMemorySftp() + const status = await new CopilotHookService().installRemote(remote.sftp, '/home/alice') + expect(status.state).toBe('installed') + const installed = remote.fs.files.get('/home/alice/.copilot/hooks/orca.json') + expect(installed).toContain('/home/alice/.orca/agent-hooks/copilot-hook.sh') + expect(installed).not.toMatch(/powershell|EncodedCommand|copilot-hook\.cmd/) + expect(remote.fs.files.get('/home/alice/.orca/agent-hooks/copilot-hook.sh')).toContain( + '#!/bin/sh' + ) + }) }) it.skipIf(process.platform === 'win32')('writes syntactically valid POSIX commands', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx b/src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx index abe57b9bf37..24c57fb09cb 100644 --- a/src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx @@ -163,10 +163,9 @@ describe('NativeChatCodeBlock', () => { ) const pre = container.querySelector('pre') - await waitFor( - () => expect(pre?.querySelector('span[style*="--syntax-dark"]')).not.toBeNull(), - { timeout: 10_000 } - ) + await waitFor(() => expect(pre?.querySelector('span[style*="--syntax-dark"]')).not.toBeNull(), { + timeout: 10_000 + }) fireEvent.click(screen.getByRole('button', { name: 'Wrap lines' }))