diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index de77b2f3e9f..eeec395b578 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -7,15 +7,111 @@ export type PosixHookEmptyPayloadPolicy = 'exit' | 'empty-object' export const POSIX_HOOK_STDIN_READER = '{ command -p cat 2>/dev/null || cat; }' export const POSIX_HOOK_STDIN_DRAIN_COMMAND = `${POSIX_HOOK_STDIN_READER} >/dev/null 2>&1 || :` +/** Seconds the JSON reader waits for the writer's first byte before giving up. + * Comfortably inside Grok's 10s hook timeout, and far enough above process + * startup that a loaded or remote host cannot lose a payload that is merely late. */ +export const POSIX_HOOK_JSON_STDIN_FIRST_BYTE_TIMEOUT_SECONDS = 5 +/** Seconds of silence that end a payload which never parses as JSON (the `cat` shape). */ +export const POSIX_HOOK_JSON_STDIN_IDLE_TIMEOUT_SECONDS = 1.5 + +// Why: Grok SessionStart writes one JSON object and then waits for the hook to +// exit without closing stdin, so reading to EOF deadlocks until Grok's 10s +// timeout. Return as soon as the first complete JSON value has arrived. +// +// Three invariants this script must hold, because the shell chains a second +// reader behind it and a reader that consumed bytes cannot be retried: +// 1. A non-zero exit implies stdin was never read, so the `||` fallback still +// sees the whole stream. Everything after the imports is therefore guarded. +// 2. Decoding is incremental. A multi-byte character straddling two reads must +// not raise, or a CJK/emoji payload falls through to `cat` and hangs. +// 3. The payload is emitted unchanged. Re-serialising would rewrite non-ASCII +// as \uXXXX and reorder keys behind the agent's back. +const POSIX_HOOK_JSON_STDIN_PYTHON = [ + 'import codecs, json, os, select', + 'text = ""', + 'try:', + ' decoder = codecs.getincrementaldecoder("utf-8")("replace")', + ` timeout = ${POSIX_HOOK_JSON_STDIN_FIRST_BYTE_TIMEOUT_SECONDS}.0`, + ' while 1:', + ' if not select.select([0], [], [], timeout)[0]:', + ' text += decoder.decode(b"", True)', + ' break', + ' chunk = os.read(0, 65536)', + ' if not chunk:', + ' text += decoder.decode(b"", True)', + ' break', + ` timeout = ${POSIX_HOOK_JSON_STDIN_IDLE_TIMEOUT_SECONDS}`, + ' text += decoder.decode(chunk)', + // raw_decode does not skip leading whitespace, so a padded payload would + // otherwise never complete and would wait out the idle timeout. + ' value = text.lstrip()', + ' if not value:', + ' continue', + ' try:', + ' end = json.JSONDecoder().raw_decode(value)[1]', + ' except ValueError:', + ' continue', + ' text = value[:end]', + ' break', + 'except Exception:', + ' pass', + 'try:', + // os.write skips the locale-dependent stdout encoder, which raises under + // LC_ALL=C for a non-ASCII payload. + ' data = text.encode("utf-8")', + ' written = 0', + ' while written < len(data):', + ' written += os.write(1, data[written:])', + 'except Exception:', + ' pass' +].join('\n') + +// Why a variable rather than two inline copies: the script is embedded twice in +// the reader chain, and `-c '<600 chars>'` twice is an EDR oversized-command-line +// signal as well as unreadable in the generated hook. +const POSIX_HOOK_JSON_STDIN_PYTHON_VAR = 'orca_hook_json_stdin_py' +export const POSIX_HOOK_JSON_STDIN_PRELUDE: readonly string[] = [ + `${POSIX_HOOK_JSON_STDIN_PYTHON_VAR}='${POSIX_HOOK_JSON_STDIN_PYTHON}'` +] + +const jsonStdinInterpreter = (name: string): string => + `command -p ${name} -c "$${POSIX_HOOK_JSON_STDIN_PYTHON_VAR}" 2>/dev/null` + +// Why: macOS ships /usr/bin/python3 as an Xcode stub that re-resolves the real +// interpreter on every run when it cannot reach its cache under $HOME. A HOME +// that does not exist costs ~6.6s per spawn there, which alone overruns Grok's +// 10s hook budget. Unsetting it brings that back to ~95ms and is what a +// home-less process sees anyway. Safe to mutate: the reader only ever runs +// inside the `payload=$(...)` subshell, so the hook's own HOME is untouched. +const POSIX_HOOK_JSON_STDIN_HOME_GUARD = '{ [ -d "${HOME:-}" ] || unset HOME; }' + +// Why `python` too: the script avoids py3-only syntax (verified on 2.7) so a host +// that only ships `python` does not drop straight to the `cat` hang. +export const POSIX_HOOK_JSON_STDIN_READER = `${POSIX_HOOK_JSON_STDIN_HOME_GUARD}; ${jsonStdinInterpreter('python3')} || ${jsonStdinInterpreter('python')} || ${POSIX_HOOK_STDIN_READER}` + +/** Optional reader override for an agent whose caller keeps stdin open after the payload. + * `prelude` must be emitted before the capture line; keep them together. */ +export type PosixHookStdinReader = { + readonly reader: string + readonly prelude: readonly string[] +} + +export const POSIX_HOOK_JSON_STDIN: PosixHookStdinReader = { + reader: POSIX_HOOK_JSON_STDIN_READER, + prelude: POSIX_HOOK_JSON_STDIN_PRELUDE +} + // Why: every POSIX hook must own stdin before any no-op exit; sharing this // prelude prevents agent templates from inventing different drain semantics. export function buildPosixHookPayloadCapture( - emptyPayloadPolicy: PosixHookEmptyPayloadPolicy = 'exit' + emptyPayloadPolicy: PosixHookEmptyPayloadPolicy = 'exit', + stdinReader: PosixHookStdinReader = { reader: POSIX_HOOK_STDIN_READER, prelude: [] } ): string[] { const emptyPayloadLines = emptyPayloadPolicy === 'empty-object' ? [" payload='{}'"] : [' exit 0'] return [ - `payload=$(${POSIX_HOOK_STDIN_READER})`, + ...stdinReader.prelude, + `payload=$(${stdinReader.reader})`, 'if [ -z "$payload" ]; then', ...emptyPayloadLines, 'fi' 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 dea4545ad11..cd6c336e751 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -64,6 +64,8 @@ import { KimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils' import { + POSIX_HOOK_JSON_STDIN_PRELUDE, + POSIX_HOOK_JSON_STDIN_READER, POSIX_HOOK_STDIN_READER, WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD } from './hook-stdin-contract' @@ -561,10 +563,22 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () it('captures stdin before every possible whole-script success exit', async () => { const scripts = await generatePosixScripts() for (const [agent, script] of scripts) { - const captureIndex = script.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`) + const captureIndex = Math.max( + script.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`), + script.indexOf(`payload=$(${POSIX_HOOK_JSON_STDIN_READER})`) + ) const firstExitIndex = script.indexOf('exit 0') expect(captureIndex, `${agent} payload capture`).toBeGreaterThanOrEqual(0) expect(firstExitIndex, `${agent} first success exit`).toBeGreaterThan(captureIndex) + // Why: the JSON reader dereferences a variable the prelude sets, so a script + // that carries the reader must carry its prelude above the capture line. + if (script.includes(POSIX_HOOK_JSON_STDIN_READER)) { + const prelude = POSIX_HOOK_JSON_STDIN_PRELUDE.join('\n') + expect(script.indexOf(prelude), `${agent} JSON reader prelude`).toBeGreaterThanOrEqual(0) + expect(script.indexOf(prelude), `${agent} prelude before capture`).toBeLessThan( + captureIndex + ) + } } }) diff --git a/src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts b/src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts new file mode 100644 index 00000000000..587346ec55e --- /dev/null +++ b/src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts @@ -0,0 +1,262 @@ +// Why an executable suite rather than shape assertions: the reader is a Python +// program embedded in a shell string, so only running it catches a decode that +// raises on a chunk boundary — the shape looked correct while a CJK payload +// crashed the interpreter and fell through to the `cat` hang it exists to avoid. +import { execFile, spawn } from 'node:child_process' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import { + buildPosixHookPayloadCapture, + POSIX_HOOK_JSON_STDIN, + POSIX_HOOK_JSON_STDIN_FIRST_BYTE_TIMEOUT_SECONDS, + POSIX_HOOK_JSON_STDIN_PRELUDE, + POSIX_HOOK_JSON_STDIN_IDLE_TIMEOUT_SECONDS +} from './hook-stdin-contract' + +const execFileAsync = promisify(execFile) + +/** The reader plus a line that prints what it captured, so the payload is observable. */ +const READER_SCRIPT = [ + ...buildPosixHookPayloadCapture('empty-object', POSIX_HOOK_JSON_STDIN).slice(0, -3), + 'printf %s "$payload"' +].join('\n') + +const REPLACEMENT_CHARACTER = '�' +const KILL_AFTER_MS = 9_000 + +type ReaderRun = { + readonly exitCode: number | null + readonly stdout: string + readonly stderr: string + readonly durationMs: number + readonly timedOut: boolean +} + +/** Feeds `chunks` with `gapMs` between them. `closeStdin: false` is the Grok + * SessionStart shape: the payload is written and the pipe is left open. */ +function runReader( + chunks: readonly Buffer[], + { + gapMs = 5, + closeStdin = false, + env + }: { gapMs?: number; closeStdin?: boolean; env?: NodeJS.ProcessEnv } = {} +): Promise { + return new Promise((resolve, reject) => { + const startedAt = Date.now() + const child = spawn('/bin/sh', ['-c', READER_SCRIPT], { + stdio: ['pipe', 'pipe', 'pipe'], + env: env ?? process.env + }) + let stdout = Buffer.alloc(0) + let stderr = '' + let timedOut = false + child.stdout.on('data', (chunk: Buffer) => { + stdout = Buffer.concat([stdout, chunk]) + }) + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + // A reader that never returns leaves the writer's pipe unread; ignore the tear-down error. + child.stdin.on('error', () => {}) + const timer = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, KILL_AFTER_MS) + child.on('error', (error) => { + clearTimeout(timer) + reject(error) + }) + child.on('close', (exitCode) => { + clearTimeout(timer) + resolve({ + exitCode, + stdout: stdout.toString('utf8'), + stderr, + durationMs: Date.now() - startedAt, + timedOut + }) + }) + void (async () => { + for (const chunk of chunks) { + child.stdin.write(chunk) + await new Promise((resolveGap) => setTimeout(resolveGap, gapMs)) + } + if (closeStdin) { + child.stdin.end() + } + })() + }) +} + +async function resolveDefaultPathPython(): Promise { + try { + const { stdout } = await execFileAsync('/bin/sh', [ + '-c', + 'command -pv python3 || command -pv python' + ]) + return stdout.trim().length > 0 + } catch { + return false + } +} + +const hasPython = process.platform === 'win32' ? false : await resolveDefaultPathPython() + +describe('POSIX hook JSON stdin reader shape', () => { + // Why: the Python program is carried in a single-quoted shell assignment, so one + // apostrophe would end the string and splice the rest of it into the hook as code. + it('carries no single quote that would escape its shell quoting', () => { + const prelude = POSIX_HOOK_JSON_STDIN_PRELUDE.join('\n') + expect(prelude.split("'")).toHaveLength(3) + }) + + it('guards HOME before the interpreter runs', () => { + expect(POSIX_HOOK_JSON_STDIN.reader.indexOf('unset HOME')).toBeLessThan( + POSIX_HOOK_JSON_STDIN.reader.indexOf('python3') + ) + }) +}) + +describe.skipIf(process.platform === 'win32')('POSIX hook JSON stdin reader', () => { + // Why skipped rather than failed: without an interpreter the chain falls back to + // `cat`, whose read-to-EOF genuinely cannot return while the writer holds the pipe. + const itWithPython = it.skipIf(!hasPython) + + itWithPython( + 'keeps a multi-byte character intact when it is split across reads', + async () => { + const payload = '{"hook_event_name":"session_start","cwd":"/tmp/漢字","tool":"🚀"}' + // One byte per write: every multi-byte sequence therefore straddles a read. + const chunks = [...Buffer.from(`${payload}\n`, 'utf8')].map((byte) => Buffer.from([byte])) + + const result = await runReader(chunks) + + expect(result.timedOut, 'reader returned').toBe(false) + expect(result.exitCode).toBe(0) + expect(result.stdout).not.toContain(REPLACEMENT_CHARACTER) + expect(result.stdout).toBe(payload) + }, + KILL_AFTER_MS + 1_000 + ) + + // Why: the chain is `python3 || python || cat`, and a reader that died after + // consuming bytes would hand the next one a truncated stream. A non-zero exit + // must therefore imply nothing was read. + itWithPython( + 'never hands a partially consumed stream to the fallback reader', + async () => { + const payload = `{"hook_event_name":"session_start","pad":"${'p'.repeat(200_000)}","cwd":"/漢"}` + const bytes = Buffer.from(`${payload}\n`, 'utf8') + // Split inside the 3-byte sequence, with a gap long enough that the first + // read has already completed before the continuation bytes are written. + const splitAt = bytes.length - 4 + const chunks = [bytes.subarray(0, splitAt), bytes.subarray(splitAt)] + + const result = await runReader(chunks, { gapMs: 400, closeStdin: true }) + + expect(result.timedOut, 'reader returned').toBe(false) + expect(result.exitCode).toBe(0) + const parsePayload = (): unknown => JSON.parse(result.stdout) + expect(parsePayload).not.toThrow() + expect(result.stdout).toBe(payload) + }, + KILL_AFTER_MS + 1_000 + ) + + // Why: re-serialising the object would rewrite non-ASCII as \uXXXX and reorder + // keys, so the hook server would no longer see what the agent actually sent. + itWithPython( + 'emits the payload text unchanged rather than re-serialising it', + async () => { + const payload = '{"z":"日本語","a":1,"nested":{"b":[1,2]}}' + + const result = await runReader([Buffer.from(`${payload}\n`, 'utf8')]) + + expect(result.timedOut, 'reader returned').toBe(false) + expect(result.stdout).not.toContain('\\u') + expect(result.stdout).toBe(payload) + }, + KILL_AFTER_MS + 1_000 + ) + + // Why: raw_decode does not skip leading whitespace, so a padded payload would + // otherwise never complete and would sit out the idle timeout before returning. + itWithPython( + 'returns immediately for a payload preceded by whitespace', + async () => { + const payload = '{"hook_event_name":"session_start"}' + + const result = await runReader([Buffer.from(`\n ${payload}\n`, 'utf8')]) + + expect(result.timedOut, 'reader returned').toBe(false) + expect(result.stdout).toBe(payload) + expect(result.durationMs).toBeLessThan(POSIX_HOOK_JSON_STDIN_IDLE_TIMEOUT_SECONDS * 1_000) + }, + KILL_AFTER_MS + 1_000 + ) + + // Why: the first-byte wait is not the idle wait. A host that is slow to schedule + // the writer must not have its payload silently dropped. + itWithPython( + 'waits past the idle timeout for a writer that has not sent its first byte', + async () => { + const payload = '{"hook_event_name":"session_start"}' + + const result = await runReader([Buffer.alloc(0), Buffer.from(`${payload}\n`)], { + gapMs: 2_500 + }) + + expect(result.timedOut, 'reader returned').toBe(false) + expect(result.stdout).toBe(payload) + expect(result.durationMs).toBeLessThan( + POSIX_HOOK_JSON_STDIN_FIRST_BYTE_TIMEOUT_SECONDS * 1_000 + ) + }, + KILL_AFTER_MS + 1_000 + ) + + // Why: not every hook payload is JSON, and the reader replaces `cat` for Grok — + // it still has to hand back everything a closed stream contained. + itWithPython( + 'reads a non-JSON payload through to EOF', + async () => { + const result = await runReader([Buffer.from('not json at all\nsecond line\n')], { + closeStdin: true + }) + + expect(result.timedOut, 'reader returned').toBe(false) + expect(result.stdout).toBe('not json at all\nsecond line') + }, + KILL_AFTER_MS + 1_000 + ) + + // Why: macOS resolves /usr/bin/python3 through an Xcode stub that re-runs its + // whole tool lookup when it cannot reach a cache under $HOME. A HOME pointing + // nowhere cost ~6.6s per spawn, which on its own overran Grok's 10s budget. + itWithPython( + 'stays fast when HOME points at a directory that does not exist', + async () => { + const payload = '{"hook_event_name":"session_start"}' + + const result = await runReader([Buffer.from(`${payload}\n`, 'utf8')], { + env: { ...process.env, HOME: '/nonexistent/orca-hook-home' } + }) + + expect(result.timedOut, 'reader returned').toBe(false) + expect(result.stdout).toBe(payload) + expect(result.durationMs).toBeLessThan(2_000) + }, + KILL_AFTER_MS + 1_000 + ) + + itWithPython( + 'reports nothing on stderr on any of these paths', + async () => { + const result = await runReader([Buffer.from('{"a":"漢"}\n', 'utf8')]) + + expect(result.stderr).toBe('') + }, + KILL_AFTER_MS + 1_000 + ) +}) diff --git a/src/main/grok/grok-hook-script.ts b/src/main/grok/grok-hook-script.ts index 997bf7e64d4..4a9025fcfb1 100644 --- a/src/main/grok/grok-hook-script.ts +++ b/src/main/grok/grok-hook-script.ts @@ -5,7 +5,8 @@ import { } from '../agent-hooks/installer-utils' import { buildPosixHookPayloadCapture, - buildPosixHookSpoolLines + buildPosixHookSpoolLines, + POSIX_HOOK_JSON_STDIN } from '../agent-hooks/hook-stdin-contract' import { buildWindowsGrokHookScript, @@ -35,7 +36,7 @@ export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): strin return [ '#!/bin/sh', - ...buildPosixHookPayloadCapture(), + ...buildPosixHookPayloadCapture('exit', POSIX_HOOK_JSON_STDIN), ...buildPosixHookSpoolLines('grok'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', diff --git a/src/main/grok/grok-hook-stdin-no-eof.test.ts b/src/main/grok/grok-hook-stdin-no-eof.test.ts new file mode 100644 index 00000000000..c8b34ed3164 --- /dev/null +++ b/src/main/grok/grok-hook-stdin-no-eof.test.ts @@ -0,0 +1,92 @@ +import { spawn } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getGrokManagedScript } from './grok-hook-script' + +// Grok's own hook timeout; the budget these cases have to stay well inside. +const GROK_HOOK_TIMEOUT_MS = 10_000 +const SESSION_START_BUDGET_MS = 1_500 + +describe.skipIf(process.platform === 'win32')('Grok POSIX hook stdin without EOF', () => { + let dir = '' + + afterEach(() => { + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + /** Writes the payload and leaves the pipe open, which is what Grok SessionStart does. */ + async function runHookWithoutEof( + chunks: readonly Buffer[] + ): Promise<{ exitCode: number | null; durationMs: number; stderr: string }> { + dir = mkdtempSync(join(tmpdir(), 'orca-grok-hook-no-eof-')) + const scriptPath = join(dir, 'grok-hook.sh') + writeFileSync(scriptPath, getGrokManagedScript('posix'), { mode: 0o755 }) + + const startedAt = Date.now() + const child = spawn('/bin/sh', [scriptPath], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + ORCA_PANE_KEY: 'pane-1', + ORCA_AGENT_HOOK_PORT: '', + ORCA_AGENT_HOOK_TOKEN: '', + ORCA_AGENT_HOOK_ENDPOINT: '' + } + }) + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + child.stdin.on('error', () => {}) + + const exitCode = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`hook still blocked on stdin after ${GROK_HOOK_TIMEOUT_MS}ms`)) + }, GROK_HOOK_TIMEOUT_MS) + child.on('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + child.on('close', (code) => { + clearTimeout(timeout) + resolve(code) + }) + void (async () => { + for (const chunk of chunks) { + child.stdin.write(chunk) + await new Promise((resolveGap) => setTimeout(resolveGap, 5)) + } + })() + }) + + return { exitCode, durationMs: Date.now() - startedAt, stderr } + } + + it('returns after one JSON object when the caller never closes stdin (SessionStart)', async () => { + const result = await runHookWithoutEof([ + Buffer.from('{"hook_event_name":"session_start","session_id":"abc"}\n') + ]) + + expect(result.exitCode).toBe(0) + expect(result.durationMs).toBeLessThan(SESSION_START_BUDGET_MS) + }) + + // Why: a non-ASCII payload arriving in pieces used to crash the reader, which + // fell through to `cat` and reinstated the very 10s timeout this hook avoids. + it('returns just as fast when a multi-byte payload is split across writes', async () => { + const bytes = Buffer.from( + '{"hook_event_name":"session_start","cwd":"/tmp/漢字","tool":"🚀"}\n', + 'utf8' + ) + const result = await runHookWithoutEof([...bytes].map((byte) => Buffer.from([byte]))) + + expect(result.stderr).toBe('') + expect(result.exitCode).toBe(0) + expect(result.durationMs).toBeLessThan(SESSION_START_BUDGET_MS) + }, 20_000) +}) diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 620966bd6ae..fe6d16a4664 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -29,7 +29,10 @@ vi.mock('os', async () => { import { getGrokToolEventMatcherForTests, GrokHookService } from './hook-service' import { buildWindowsGrokHookScript } from './windows-grok-hook-script' -import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' +import { + POSIX_HOOK_JSON_STDIN_PRELUDE, + POSIX_HOOK_JSON_STDIN_READER +} from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = @@ -298,7 +301,15 @@ describe('GrokHookService', () => { } else { // Why: payload is piped to curl via stdin (`payload@-`) so it never lands // on the curl command line (EDR oversized-command-line false positive). - expect(script).toContain(`payload=$(${POSIX_HOOK_STDIN_READER})`) + // Why the ordering: the reader chain dereferences the prelude's variable, so a + // prelude emitted after the capture would silently run `python -c ""` and + // hand back an empty payload. + const prelude = POSIX_HOOK_JSON_STDIN_PRELUDE.join('\n') + expect(script).toContain(prelude) + expect(script.indexOf(prelude)).toBeLessThan( + script.indexOf(`payload=$(${POSIX_HOOK_JSON_STDIN_READER})`) + ) + expect(script).toContain(`payload=$(${POSIX_HOOK_JSON_STDIN_READER})`) expect(script).toContain('printf \'%s\' "$payload" | curl') expect(script).toContain('--data-urlencode "payload@-"') expect(script).toContain('${#GROK_HOME}" -le 4096')