mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(grok): stop SessionStart orca-status hook hanging for 10s (#20090)
* fix(grok): stop SessionStart orca-status hook hanging for 10s
Grok writes one JSON hook payload and waits for the process to exit
without closing stdin. The POSIX hook used `cat`, which waits for EOF,
so SessionStart deadlocked until Grok's 10s timeout:
session_start hook (global/orca-status) failed, ignored: timed out after 10000ms
Read one JSON object with raw_decode instead; return as soon as the
object is complete. Fall back to cat when Python is missing.
* fix(grok): decode the hook payload incrementally instead of per chunk
The JSON stdin reader strict-decoded the whole accumulated buffer after
every read and caught only json.JSONDecodeError. A multi-byte character
split across two os.read calls therefore raised UnicodeDecodeError, which
is a ValueError but not a JSONDecodeError, so the interpreter died — after
consuming stdin. The `python3 || python || cat` chain then handed the next
reader a truncated stream, and `cat` blocked on a pipe the caller never
closes, reinstating the exact 10s SessionStart timeout this reader exists
to avoid. Measured: a CJK+emoji payload written byte by byte hung until it
was killed; a 200KB payload split mid-character arrived as 4 bytes.
Hold the decoder across reads, treat any ValueError as "not complete yet",
and guard the whole program so a non-zero exit implies stdin was never
read — only then is the `||` fallback safe. Emit the object's own text
rather than re-serialising it, which was rewriting non-ASCII as \uXXXX.
Skip leading whitespace, which raw_decode does not. Separate the
first-byte wait (5s) from the idle wait (1.5s) so a writer that is merely
late is no longer dropped.
Also unset a HOME that does not exist before spawning the interpreter:
macOS resolves /usr/bin/python3 through an Xcode stub that re-runs its
whole tool lookup without a reachable cache, costing 6.6s per spawn and
overrunning Grok's budget on its own. This is what made the existing
large-payload and empty-PATH lifecycle cases fail.
The Python program now lives in a shell variable instead of being inlined
twice, which halves the generated script and keeps it readable.
---------
Co-authored-by: Neil <neil@stably.ai>
(cherry picked from commit 01f8aa8d96)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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<ReaderRun> {
|
||||
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<boolean> {
|
||||
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
|
||||
)
|
||||
})
|
||||
@@ -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 || :',
|
||||
|
||||
@@ -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<number | null>((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)
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user