Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/tsconfig.cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"../src/main/codebuddy/hook-service.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-post-command.ts",
"../src/main/agent-hooks/hook-process-capture.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
"../src/main/agent-hooks/hooks-json-read.ts",
"../src/main/agent-hooks/installer-utils.ts",
Expand Down
2 changes: 2 additions & 0 deletions src/main/agent-hooks/hook-post-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function buildPosixAgentHookPostCommand(
` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`,
` ${indent}-H "X-Orca-Agent-Hook-Meta-Encoding: base64" \\`,
` ${indent}-H "X-Orca-Agent-Hook-Meta: \${orca_hook_metadata}" \\`,
` ${indent}-H "X-Orca-Agent-Process: \${orca_agent_process:-}" \\`,
` ${indent}--data-binary @-`,
'else',
` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`,
Expand All @@ -31,6 +32,7 @@ export function buildPosixAgentHookPostCommand(
` ${indent}--data-urlencode "worktreeId=\${ORCA_WORKTREE_ID}" \\`,
` ${indent}--data-urlencode "env=\${ORCA_AGENT_HOOK_ENV}" \\`,
` ${indent}--data-urlencode "version=\${ORCA_AGENT_HOOK_VERSION}" \\`,
` ${indent}--data-urlencode "agentProcess=\${orca_agent_process:-}" \\`,
` ${indent}--data-urlencode "payload@-"`,
'fi'
]
Expand Down
50 changes: 50 additions & 0 deletions src/main/agent-hooks/hook-process-capture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { runProcess } from '../../shared/child-process/run-process'
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
import { buildHookProcessCapture } from './hook-process-capture'
import { readAgentProcessIdentity } from '../../shared/agent-process-presence'
import { probeAgentProcessPresence } from '../../shared/agent-process-presence-probe'

describe('Claude outer hook process capture', () => {
it.skipIf(process.platform === 'win32').each([undefined, 'Asia/Tokyo'])(
'captures the parent before the managed script shell, under an empty environment (TZ=%s)',
async (timeZone) => {
const home = await mkdtemp(join(tmpdir(), 'orca-presence-hook-'))
try {
const dir = join(home, '.orca', 'agent-hooks')
await mkdir(dir, { recursive: true })
await writeFile(
join(dir, 'claude-hook.sh'),
['#!/bin/sh', ...buildHookProcessCapture(), 'printf "%s" "$orca_agent_process"'].join(
'\n'
),
{ mode: 0o700 }
)
const result = await runProcess({
program: '/usr/bin/env',
args: [
'-i',
`HOME=${home}`,
'PATH=/usr/bin:/bin',
'ORCA_PANE_KEY=presence-test-pane',
// Why: the agent's shell zone may differ from Orca's; the identity must not.
...(timeZone ? [`TZ=${timeZone}`] : []),
'/bin/sh',
'-c',
wrapRuntimeHomeHookCommand('claude-hook')
],
timeoutMs: 5000
})
expect(result.code).toBe(0)
const identity = readAgentProcessIdentity(result.stdout)
expect(identity?.pid).toBe(process.pid)
expect(await probeAgentProcessPresence(identity)).toBe('live')
} finally {
await rm(home, { recursive: true, force: true })
}
}
)
})
24 changes: 24 additions & 0 deletions src/main/agent-hooks/hook-process-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** Capture before Orca inserts its managed-script shell. */
export const CLAUDE_HOOK_PARENT_CAPTURE =
'ORCA_HOOK_AGENT_PID="${PPID:-}"; export ORCA_HOOK_AGENT_PID; '

export function buildHookProcessCapture(): string[] {
return [
'orca_agent_process=',
// Why: skip the process reads for Claude sessions outside an Orca pane.
'[ -z "${ORCA_PANE_KEY:-}" ] || case "${ORCA_HOOK_AGENT_PID:-}" in ""|*[!0-9]*) ;; *)',
' if [ -r "/proc/$ORCA_HOOK_AGENT_PID/stat" ]; then',
' orca_agent_stat=$(cat "/proc/$ORCA_HOOK_AGENT_PID/stat" 2>/dev/null) || orca_agent_stat=',
' orca_agent_boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || orca_agent_boot=',
' orca_agent_fields=${orca_agent_stat##*) }',
' orca_agent_start=$(printf "%s" "$orca_agent_fields" | awk \'{print $20}\')',
' case "$orca_agent_start" in ""|*[!0-9]*) ;; *)',
' [ -z "$orca_agent_boot" ] || orca_agent_process=$(printf \'{"pid":%s,"platform":"linux","startTime":"%s:%s"}\' "$ORCA_HOOK_AGENT_PID" "$orca_agent_boot" "$orca_agent_start") ;; esac',
' elif [ "$(uname -s 2>/dev/null)" = Darwin ]; then',
// Why: lstart is local time; the host probe pins the same zone so the strings compare.
' orca_agent_start=$(TZ=UTC0 LC_ALL=C /bin/ps -p "$ORCA_HOOK_AGENT_PID" -o lstart= 2>/dev/null | sed \'s/^ *//;s/ *$//\')',
' [ -z "$orca_agent_start" ] || orca_agent_process=$(printf \'{"pid":%s,"platform":"darwin","startTime":"%s"}\' "$ORCA_HOOK_AGENT_PID" "$orca_agent_start")',
' fi ;;',
'esac'
]
}
4 changes: 2 additions & 2 deletions src/main/agent-hooks/hook-stdin-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,11 @@ export function buildPosixHookSpoolLines(source: string, eventNameVar?: string):
const eventArg = eventNameVar ? ` "$(spool_json_escape "\${${eventNameVar}:-}")"` : ''
const spoolRecordLine = " { printf '\\n{".concat(
eventFormat,
'"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","launchToken":"%s","source":"%s","receivedAt":%s,"payload":%s}\\n\'',
'"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","launchToken":"%s","source":"%s","receivedAt":%s,"agentProcess":"%s","payload":%s}\\n\'',
eventArg,
' "$(spool_json_escape "${ORCA_PANE_KEY:-}")" "$(spool_json_escape "${ORCA_TAB_ID:-}")" "$(spool_json_escape "${ORCA_WORKTREE_ID:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_ENV:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_VERSION:-}")" "$(spool_json_escape "${ORCA_AGENT_LAUNCH_TOKEN:-}")" "$(spool_json_escape "',
source,
'")" "$spool_now" "$payload"; } >> "$spool_file" 2>/dev/null || :'
'")" "$spool_now" "$(spool_json_escape "${orca_agent_process:-}")" "$payload"; } >> "$spool_file" 2>/dev/null || :'
)
return [
'spool_hook_event() {',
Expand Down
3 changes: 2 additions & 1 deletion src/main/agent-hooks/runtime-home-hook-command.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CLAUDE_HOOK_PARENT_CAPTURE } from './hook-process-capture'
import {
POSIX_HOOK_STDIN_DRAIN_COMMAND,
WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD,
Expand Down Expand Up @@ -46,7 +47,7 @@ export function wrapRuntimeHomeHookCommand(
const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}`
const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${windowsMissingScriptFallback}; fi`
const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${windowsMissingScriptFallback}; fi`
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi`
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then ${scriptBaseName === 'claude-hook' || scriptBaseName === 'openclaude-hook' ? CLAUDE_HOOK_PARENT_CAPTURE : ''}/bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi`
// Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation.
return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi`
}
Loading
Loading