mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* fix(agent-hooks): register forward-slash Windows hook paths for Grok, Devin, ZCode and Antigravity These four agents registered their native-Windows status hook as a bare backslash path. A Git Bash hook host strips the backslashes, the command exits 127, and no status event reaches Orca. Use the same direct forward-slash form Claude, Codex and Muse already register, keeping the cmd-safe gate and the encoded PowerShell fallback. Existing backslash entries still match the managed-command matcher, so the next install replaces them and keeps user hooks. * 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 * test(agent-hooks): expect forward-slash Antigravity hooks after the Unicode upgrade ASCII profiles now register the direct forward-slash wrapper path, so the upgrade-delivery test expects that form and also runs it through Git Bash. Unicode profiles keep the encoded launcher. * test(agent-hooks): assert the registered hook lives under the mocked home Pins that the os mock reaches the agents' node:os imports, so the host test never installs into the real profile. * 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 * refactor(agent-hooks): register Grok, Devin, ZCode and Antigravity through wrapWindowsBatchHookCommand Use the shared batch-hook helper from #27717 instead of an inline direct-or-cmd fallback. A UNC home is cmd-safe but has no drive letter; the old fallback registered it as a bare backslash path that Git Bash cannot start, while the helper sends it to the encoded launcher. wrapWindowsCmdHookCommand has no production callers left, so it is removed and its tests point at the helper. Grok, Devin and ZCode join the Windows payload delivery table with a legacy backslash entry to sweep, replacing the separate host test, and the Antigravity migration test now runs on every platform. --------- Co-authored-by: Orca Worker <orca-worker@localhost>
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
CLAUDE_HOOK_SETTINGS,
|
|
OPENCLAUDE_HOOK_SETTINGS,
|
|
getManagedEventHook,
|
|
getManagedLifecycleHook,
|
|
getRemoteManagedCommand as getClaudeRemoteCommand,
|
|
getRemoteManagedEventCommand
|
|
} from '../claude/hook-settings'
|
|
import {
|
|
getManagedCommand as getCodexCommand,
|
|
wrapReadablePosixHookCommand
|
|
} from '../codex/codex-hook-definition'
|
|
import { ANTIGRAVITY_EVENTS, ANTIGRAVITY_PRE_TOOL_USE_DECISION } from '../antigravity/hook-events'
|
|
import { CURSOR_EVENTS } from '../cursor/hook-events'
|
|
import {
|
|
getManagedCommand as getCursorCommand,
|
|
getPosixManagedCommand as getCursorRemoteCommand
|
|
} from '../cursor/hook-script'
|
|
import {
|
|
COPILOT_EVENTS,
|
|
getManagedCommand as getCopilotCommand
|
|
} from '../copilot/copilot-managed-hook-definitions'
|
|
import { getDevinManagedCommand, getDevinRemoteManagedCommand } from '../devin/hook-settings'
|
|
import { getGrokManagedCommand } from '../grok/grok-hook-script'
|
|
import { getMuseManagedCommand, getMuseRemoteManagedCommand } from '../muse/hook-settings'
|
|
import { getDshManagedCommand, getDshRemoteManagedCommand } from '../dsh/hook-settings'
|
|
import { getZCodeManagedCommand, getZCodeRemoteManagedCommand } from '../zcode/hook-settings'
|
|
import { getJcodeManagedCommand, getJcodeRemoteManagedCommand } from '../jcode/hook-settings'
|
|
import { getKiroManagedCommand, getKiroRemoteManagedCommand } from '../kiro/hook-settings'
|
|
import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils'
|
|
import { wrapWindowsBatchHookCommand } from './windows-direct-cmd-hook-command'
|
|
import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry'
|
|
import { REMOTE_MANAGED_HOOK_INSTALLER_AGENTS } from './remote-managed-hook-installers'
|
|
import {
|
|
findBareHookCommandVariables,
|
|
GROK_PROVIDED_HOOK_VARIABLES
|
|
} from './managed-hook-command-env.test-fixture'
|
|
|
|
vi.mock('electron', () => ({ app: { getPath: () => process.cwd() } }))
|
|
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
type CommandBuilders = {
|
|
local: (scriptPath: string) => string[]
|
|
remote: (scriptPath: string) => string[]
|
|
}
|
|
|
|
// Why: Gemini/Droid/Command Code keep their thin builders private; exercise the wrappers they call.
|
|
const standardCommands: CommandBuilders = {
|
|
local: (path) => [
|
|
process.platform === 'win32' ? wrapWindowsHookCommand(path) : wrapPosixHookCommand(path)
|
|
],
|
|
remote: (path) => [wrapPosixHookCommand(path)]
|
|
}
|
|
|
|
function antigravityPosixCommands(path: string): string[] {
|
|
return ANTIGRAVITY_EVENTS.map((event) =>
|
|
wrapPosixHookCommand(
|
|
path,
|
|
{ ORCA_ANTIGRAVITY_EVENT: event.eventName },
|
|
event.eventName === 'PreToolUse' ? { fallbackStdout: ANTIGRAVITY_PRE_TOOL_USE_DECISION } : {}
|
|
)
|
|
)
|
|
}
|
|
|
|
function claudeFamilyBuilders(settings: typeof CLAUDE_HOOK_SETTINGS): CommandBuilders {
|
|
return {
|
|
local: (path) => [
|
|
getManagedLifecycleHook(path, settings).command,
|
|
getManagedEventHook('PostCompact', path, settings).command
|
|
],
|
|
remote: (path) => [
|
|
getClaudeRemoteCommand(path),
|
|
getRemoteManagedEventCommand('PostCompact', path)
|
|
]
|
|
}
|
|
}
|
|
|
|
const buildersByAgent = new Map<string, CommandBuilders>([
|
|
['claude', claudeFamilyBuilders(CLAUDE_HOOK_SETTINGS)],
|
|
['openclaude', claudeFamilyBuilders(OPENCLAUDE_HOOK_SETTINGS)],
|
|
[
|
|
'qoder',
|
|
{
|
|
local: (path) => [
|
|
getManagedLifecycleHook(path, {
|
|
configDirName: '.qoder',
|
|
scriptBaseName: 'qoder-hook',
|
|
usesWindowsCompatLauncher: true,
|
|
windowsHookShell: 'powershell'
|
|
}).command
|
|
],
|
|
remote: (path) => [getClaudeRemoteCommand(path)]
|
|
}
|
|
],
|
|
[
|
|
'qoder-cn',
|
|
{
|
|
local: (path) => [
|
|
getManagedLifecycleHook(path, {
|
|
configDirName: '.qoder-cn',
|
|
scriptBaseName: 'qoder-cn-hook',
|
|
usesWindowsCompatLauncher: true,
|
|
windowsHookShell: 'powershell'
|
|
}).command
|
|
],
|
|
remote: (path) => [getClaudeRemoteCommand(path)]
|
|
}
|
|
],
|
|
[
|
|
'qwen-code',
|
|
{
|
|
local: (path) => [
|
|
getManagedLifecycleHook(path, {
|
|
configDirName: '.qwen',
|
|
scriptBaseName: 'qwen-code-hook',
|
|
usesWindowsCompatLauncher: true,
|
|
windowsHookShell: 'powershell'
|
|
}).command
|
|
],
|
|
remote: (path) => [getClaudeRemoteCommand(path)]
|
|
}
|
|
],
|
|
[
|
|
'codebuddy',
|
|
{
|
|
local: (path) => [
|
|
getManagedLifecycleHook(path, {
|
|
configDirName: '.codebuddy',
|
|
scriptBaseName: 'codebuddy-hook',
|
|
usesWindowsCompatLauncher: true
|
|
}).command
|
|
],
|
|
remote: (path) => [getClaudeRemoteCommand(path)]
|
|
}
|
|
],
|
|
[
|
|
'codex',
|
|
{
|
|
local: (path) => [getCodexCommand(path), wrapReadablePosixHookCommand(path)],
|
|
remote: (path) => [wrapPosixHookCommand(path), wrapReadablePosixHookCommand(path)]
|
|
}
|
|
],
|
|
['gemini', standardCommands],
|
|
[
|
|
'antigravity',
|
|
{
|
|
local: (path) =>
|
|
process.platform === 'win32'
|
|
? ANTIGRAVITY_EVENTS.map((event) =>
|
|
wrapWindowsBatchHookCommand(
|
|
path.replace('antigravity-hook.cmd', event.windowsWrapperFileName)
|
|
)
|
|
)
|
|
: antigravityPosixCommands(path),
|
|
remote: antigravityPosixCommands
|
|
}
|
|
],
|
|
[
|
|
'cursor',
|
|
{
|
|
local: (path) => CURSOR_EVENTS.map((event) => getCursorCommand(path, event)),
|
|
remote: (path) => CURSOR_EVENTS.map((event) => getCursorRemoteCommand(path, event))
|
|
}
|
|
],
|
|
['droid', standardCommands],
|
|
['command-code', standardCommands],
|
|
[
|
|
'grok',
|
|
{
|
|
local: (path) => [getGrokManagedCommand(path)],
|
|
// Why: grok-hook-remote-install.ts calls this wrapper directly, with the pane guard.
|
|
remote: (path) => [wrapPosixHookCommand(path, {}, { requiredEnvVar: 'ORCA_PANE_KEY' })]
|
|
}
|
|
],
|
|
[
|
|
'copilot',
|
|
{
|
|
local: (path) => COPILOT_EVENTS.map((event) => getCopilotCommand(path, event)),
|
|
remote: (path) =>
|
|
COPILOT_EVENTS.map((event) =>
|
|
wrapPosixHookCommand(path, { ORCA_COPILOT_HOOK_EVENT: event })
|
|
)
|
|
}
|
|
],
|
|
[
|
|
'devin',
|
|
{
|
|
local: (path) => [getDevinManagedCommand(path)],
|
|
remote: (path) => [getDevinRemoteManagedCommand(path)]
|
|
}
|
|
],
|
|
[
|
|
'kimi',
|
|
{
|
|
local: (path) => [wrapPosixHookCommand(path.replaceAll('\\', '/'))],
|
|
remote: (path) => [wrapPosixHookCommand(path)]
|
|
}
|
|
],
|
|
[
|
|
'muse',
|
|
{
|
|
local: (path) => [getMuseManagedCommand(path)],
|
|
remote: (path) => [getMuseRemoteManagedCommand(path)]
|
|
}
|
|
],
|
|
[
|
|
'dsh',
|
|
{
|
|
local: (path) => [getDshManagedCommand(path)],
|
|
remote: (path) => [getDshRemoteManagedCommand(path)]
|
|
}
|
|
],
|
|
[
|
|
'zcode',
|
|
{
|
|
local: (path) => [getZCodeManagedCommand(path)],
|
|
remote: (path) => [getZCodeRemoteManagedCommand(path)]
|
|
}
|
|
],
|
|
[
|
|
// Why bare: jcode parses the hook command line shell-style but executes it
|
|
// directly, so a `sh -c`/`if [ -f … ]` wrapper would be run as the program name.
|
|
'jcode',
|
|
{
|
|
local: (path) => [getJcodeManagedCommand(path)],
|
|
remote: (path) => [getJcodeRemoteManagedCommand(path)]
|
|
}
|
|
],
|
|
[
|
|
'kiro',
|
|
{
|
|
local: (path) => [getKiroManagedCommand(path)],
|
|
remote: (path) => [getKiroRemoteManagedCommand(path)]
|
|
}
|
|
]
|
|
])
|
|
|
|
// Why: as in MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS, native plugin source has no shell command to scan.
|
|
const exemptionsByAgent = new Map([
|
|
['amp', 'Native TypeScript plugin source; no shell hook command'],
|
|
['hermes', 'Native Python plugin source; no shell hook command']
|
|
])
|
|
|
|
describe('managed hook command contract', () => {
|
|
it.each([
|
|
['local', MANAGED_AGENT_HOOK_INSTALLERS.map(([agent]) => agent)],
|
|
['remote', REMOTE_MANAGED_HOOK_INSTALLER_AGENTS]
|
|
] as const)('covers the %s installer registry in both directions', (_target, agents) => {
|
|
// Why: mirror the remote installer coverage ratchet (#7253); a new provider cannot opt out silently.
|
|
for (const agent of agents) {
|
|
expect(
|
|
Number(buildersByAgent.has(agent)) + Number(exemptionsByAgent.has(agent)),
|
|
`${agent} needs exactly one command builder or documented native-plugin exemption`
|
|
).toBe(1)
|
|
}
|
|
const registered = new Set<string>(agents)
|
|
for (const agent of [...buildersByAgent.keys(), ...exemptionsByAgent.keys()]) {
|
|
expect(registered.has(agent), `${agent} is absent from the installer registry`).toBe(true)
|
|
}
|
|
})
|
|
|
|
describe.each(['darwin', 'linux', 'win32'] as const)('%s host', (platform) => {
|
|
it.each([...buildersByAgent])('%s emits no bare variable references', (agent, builders) => {
|
|
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
|
const extension = platform === 'win32' && agent !== 'kimi' ? 'cmd' : 'sh'
|
|
const homes =
|
|
platform === 'win32' ? ['C:/Users/test', 'C:/Users/test user'] : ['/home/test user']
|
|
const paths = homes.map((home) => `${home}/.orca/agent-hooks/${agent}-hook.${extension}`)
|
|
const commands = [
|
|
...paths.flatMap((path) => builders.local(path)),
|
|
...builders.remote(`/home/remote user/.orca/agent-hooks/${agent}-hook.sh`)
|
|
]
|
|
expect(commands.length).toBeGreaterThan(0)
|
|
for (const command of commands) {
|
|
expect(command.length).toBeGreaterThan(0)
|
|
// Native PowerShell hooks evaluate these variables without Grok's dollar-byte scanner.
|
|
const scannedCommand =
|
|
platform === 'win32' &&
|
|
(agent === 'qoder' || agent === 'qoder-cn' || agent === 'qwen-code') &&
|
|
command.startsWith('$scriptPath = Join-Path')
|
|
? command
|
|
.replaceAll('$LASTEXITCODE', '')
|
|
.replaceAll('$env:', '')
|
|
.replaceAll('$scriptPath', '')
|
|
: command
|
|
expect(findBareHookCommandVariables(scannedCommand), command).toEqual([])
|
|
}
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Grok variable scanner contract', () => {
|
|
it.each(['$NAME', '${NAME}', "'$NAME'", "'${NAME}'", '\\$NAME', '$lower_9', '${_NAME9}'])(
|
|
'rejects bare references without shell quoting state: %s',
|
|
(command) => expect(findBareHookCommandVariables(command)).toHaveLength(1)
|
|
)
|
|
|
|
it.each([
|
|
'${NAME-}',
|
|
'${NAME:-}',
|
|
'${NAME:+}',
|
|
'${NAME#x}',
|
|
'${NAME:0:5}',
|
|
'${NAME+x}',
|
|
'${NAME=x}',
|
|
'${NAME?x}',
|
|
'${NAME%x}',
|
|
'${NAME/x/y}',
|
|
'$1',
|
|
'$$',
|
|
'$?',
|
|
'$(true)'
|
|
])('allows modifiers and non-variable dollar forms: %s', (command) => {
|
|
expect(findBareHookCommandVariables(command)).toEqual([])
|
|
})
|
|
|
|
it.each(GROK_PROVIDED_HOOK_VARIABLES)('exempts only the exact provided name %s', (name) => {
|
|
expect(findBareHookCommandVariables(`$${name} \${${name}}`)).toEqual([])
|
|
expect(findBareHookCommandVariables(`$${name}_OTHER \${${name}_OTHER}`)).toHaveLength(2)
|
|
})
|
|
})
|