diff --git a/src/main/hive.ts b/src/main/hive.ts index 5f9c38bffc..8afba9f345 100644 --- a/src/main/hive.ts +++ b/src/main/hive.ts @@ -2524,7 +2524,7 @@ export class HiveManager { // Let Grok apply its event-aware defaults (5s normally, 600s for Stop). // Grok is a HOOK bridge (not a proxy sidecar), so it is hit by the same // `node: command not found` 127 — bundled node here too. - hooks: [{ type: 'command', command: this.nodeRun(shim) }] + hooks: [{ type: 'command', command: grokHookCommand(this.nodeRun(shim)) }] }); const hooks = { PreToolUse: [tool('.*')], @@ -3594,6 +3594,16 @@ process.stdin.on('end', () => { }); `; +/** Grok runs hook commands through PowerShell on Windows. There a line that + * STARTS with a quoted path (`"" ""`) is a string expression, + * not a command, so the second string is a ParserError ("Unexpected token") + * and every hook exits 1 (#749). The call operator `&` makes PowerShell invoke + * it. Applied to the bare-`node` fallback too: `& node ""` is equally + * valid, so there is one shape on win32. POSIX output is unchanged. */ +export function grokHookCommand(cmd: string, platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? `& ${cmd}` : cmd; +} + // ─── grok-hook shim (written to /bin/grok-hook.cjs) ─────────────────── // Grok's lifecycle events and decisions are Claude-compatible, but the wire // payload is camelCase and uses snake_case event values. Normalize the input for diff --git a/test/hive-hook-node.test.cjs b/test/hive-hook-node.test.cjs index db8f6515ac..898f11aba9 100644 --- a/test/hive-hook-node.test.cjs +++ b/test/hive-hook-node.test.cjs @@ -23,7 +23,7 @@ const path = require('node:path'); const { spawn } = require('node:child_process'); const loadTs = require('./load-ts.cjs'); -const { HiveManager } = loadTs('src/main/hive.ts'); +const { HiveManager, grokHookCommand } = loadTs('src/main/hive.ts'); const POSIX = process.platform !== 'win32'; const STRIPPED_PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; @@ -81,7 +81,11 @@ function hookCommandsUnder(home) { return found.filter((c) => shim.test(c)); } -const usesLauncher = (cmd, launcher) => cmd.startsWith(launcher) || cmd.startsWith(`"${launcher}"`); +// A PowerShell-run command (Grok on Windows) leads with the call operator `& `. +const usesLauncher = (cmd, launcher) => { + const c = cmd.replace(/^& /, ''); + return c.startsWith(launcher) || c.startsWith(`"${launcher}"`); +}; async function run(cmd, env) { return new Promise((resolve) => { @@ -171,6 +175,58 @@ test('every hook installer routes through the launcher — none left on bare nod } }); +test('grokHookCommand prefixes the PowerShell call operator on win32 only (#749)', () => { + const cmd = '"C:\h\hive-node.cmd" "C:\h\grok-hook.cjs"'; + assert.equal(grokHookCommand(cmd, 'win32'), `& ${cmd}`); + assert.equal(grokHookCommand('node "C:\h\grok-hook.cjs"', 'win32'), '& node "C:\h\grok-hook.cjs"'); + for (const platform of ['darwin', 'linux']) { + assert.equal(grokHookCommand('"/h/hive-node" "/h/grok-hook.cjs"', platform), '"/h/hive-node" "/h/grok-hook.cjs"'); + } +}); + +function pwshPath() { + if (process.platform !== 'win32') return null; + const { spawnSync } = require('node:child_process'); + const r = spawnSync('pwsh', ['-NoProfile', '-Command', 'exit 0'], { stdio: 'ignore' }); + return r.status === 0 ? 'pwsh' : null; +} + +test('Grok hook commands parse and run under PowerShell (#749)', { skip: !pwshPath() && 'needs pwsh on Windows', timeout: 20_000 }, async (t) => { + // Grok on Windows runs hooks via pwsh. Use a hive path with a space so the + // quoting matters as well as the call operator. + const { home, harness } = isolatedHomes(t, 'md-grok-pwsh-'); + const root = `${harness} space`; + const hive = new HiveManager(() => root); + hive.ensureHive(); + hive.installGrokHooks(); + + const config = JSON.parse(fs.readFileSync(path.join(home, '.grok', 'hooks', 'munder-hive.json'), 'utf8')); + const commands = Object.values(config.hooks).flatMap((ms) => ms.flatMap((m) => m.hooks.map((h) => h.command))); + assert.equal(commands.length, 8, 'every Grok lifecycle hook must be generated'); + for (const c of commands) assert.ok(c.startsWith('& "'), c); + + const runPwsh = (command) => new Promise((resolve) => { + // No AGENT_ID: the shim must exit 0 without touching any socket. + const env = { ...process.env }; + delete env.AGENT_ID; + const child = spawn('pwsh', ['-NoProfile', '-NonInteractive', '-Command', command], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + let stderr = ''; + child.stdout.resume(); + child.stderr.on('data', (d) => { stderr += d; }); + child.stdin.on('error', () => { /* shim may exit first */ }); + child.stdin.end(JSON.stringify({ hookEventName: 'Stop', sessionId: 's1' })); + child.on('close', (code) => resolve({ code, stderr })); + }); + + // Negative control: the pre-fix shape is a PowerShell ParserError. + const before = await runPwsh(commands[0].slice(2)); + assert.notEqual(before.code, 0, 'negative control: the unprefixed command must not parse'); + assert.match(before.stderr, /Unexpected token|ParserError/); + + const after = await runPwsh(commands[0]); + assert.equal(after.code, 0, `Grok hook failed under pwsh: ${after.stderr}`); +}); + test('Codex hook commands survive a hive path containing spaces', { skip: !POSIX, timeout: 10_000 }, async (t) => { // Keep this root short enough for macOS's Unix-domain socket path limit while // putting the literal space after a unique component. The shell's first token