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' }))