mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import { dirname } from 'node:path'
|
|
import type { SFTPWrapper } from 'ssh2'
|
|
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
|
import {
|
|
buildWindowsAgentHookPostCommand,
|
|
createManagedCommandMatcher,
|
|
writeManagedScript
|
|
} from '../agent-hooks/installer-utils'
|
|
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
|
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
|
|
import {
|
|
readTextFileRemote,
|
|
writeManagedScriptRemote,
|
|
writeTextFileRemoteAtomic
|
|
} from '../agent-hooks/installer-utils-remote'
|
|
import {
|
|
buildWindowsHookEnvironmentGuardLines,
|
|
buildWindowsHookStdinDrainEpilogue,
|
|
POSIX_HOOK_STDIN_DRAIN_COMMAND,
|
|
WINDOWS_HOOK_STDIN_DRAIN_COMMAND
|
|
} from '../agent-hooks/hook-stdin-contract'
|
|
import {
|
|
applyJcodeManagedHooks,
|
|
parseJcodeHooksTable,
|
|
removeJcodeManagedHooks
|
|
} from './hook-config'
|
|
import {
|
|
getJcodeConfigPath,
|
|
getJcodeManagedCommand,
|
|
getJcodeManagedScriptFileName,
|
|
getJcodeManagedScriptPath,
|
|
getJcodePosixManagedScriptFileName,
|
|
getJcodeRemoteConfigPath,
|
|
getJcodeRemoteManagedCommand,
|
|
JCODE_HOOK_EVENTS
|
|
} from './hook-settings'
|
|
|
|
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|
if (target === 'local' && process.platform === 'win32') {
|
|
// Why a temp file rather than stdin: the shared builder posts `payload@-`, and on
|
|
// Windows jcode's payload never reaches stdin — the pre_tool gate has already
|
|
// drained it, and observer hooks are given a null stdin. Without this the server
|
|
// sees no event name and normalizeJcodeEvent drops every event, so a Windows pane
|
|
// would show no jcode status at all.
|
|
const payloadFile = '%ORCA_JCODE_PAYLOAD_FILE%'
|
|
return [
|
|
'@echo off',
|
|
// EnableDelayedExpansion so `!JCODE_HOOK_PAYLOAD!` is written verbatim: plain
|
|
// `%VAR%` expansion re-parses the JSON's quotes and `&` as batch syntax.
|
|
'setlocal EnableDelayedExpansion',
|
|
// Why: endpoint file holds the live port/token; a PTY that outlives an Orca restart carries stale env, so `call` it to refresh (else PTY env).
|
|
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
|
// Why the guard comes first here, unlike the POSIX script: on Windows a hook
|
|
// that owns stdin outside an Orca pane can hang forever, because the caller
|
|
// abandons the pipe rather than closing it (#11549). A jcode gate that exits
|
|
// without draining costs at most jcode's own 5s pre_tool timeout, which fails
|
|
// open; a hung hook process costs a stranded window per event.
|
|
...buildWindowsHookEnvironmentGuardLines(),
|
|
// Why: inside an Orca pane, pre_tool is jcode's gate — it writes the tool input
|
|
// to our stdin and waits for us, so drain it before the POST or a tool input
|
|
// larger than the pipe buffer stalls the agent mid-write.
|
|
`if "%JCODE_HOOK_EVENT%"=="pre_tool" ${WINDOWS_HOOK_STDIN_DRAIN_COMMAND}`,
|
|
`set "ORCA_JCODE_PAYLOAD_FILE=%TEMP%\\orca-jcode-hook-%RANDOM%%RANDOM%.json"`,
|
|
`>"${payloadFile}" echo(!JCODE_HOOK_PAYLOAD!`,
|
|
// Input redirection avoids spawning cmd's extra pipeline shells on every event.
|
|
`<"${payloadFile}" ${buildWindowsAgentHookPostCommand('jcode', [
|
|
' --data-urlencode "hook_event_name=%JCODE_HOOK_EVENT%" ^',
|
|
' --data-urlencode "session_id=%JCODE_HOOK_SESSION_ID%" ^',
|
|
' --data-urlencode "cwd=%JCODE_HOOK_CWD%" ^'
|
|
])}`,
|
|
`del "${payloadFile}" 2>nul`,
|
|
'exit /b 0',
|
|
...buildWindowsHookStdinDrainEpilogue(),
|
|
''
|
|
].join('\r\n')
|
|
}
|
|
|
|
return [
|
|
'#!/bin/sh',
|
|
// Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env).
|
|
// Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race or CRLF-mangled line can't leak shell parse errors into agent transcripts (fail-open).
|
|
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
|
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
|
'fi',
|
|
// Why before the env guard: pre_tool is jcode's gate — it writes the tool input to
|
|
// our stdin and waits for us, so stdin must be drained before ANY exit path or a
|
|
// tool input larger than the pipe buffer stalls the agent mid-write.
|
|
'if [ "$JCODE_HOOK_EVENT" = pre_tool ]; then',
|
|
` ${POSIX_HOOK_STDIN_DRAIN_COMMAND}`,
|
|
'fi',
|
|
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
|
' exit 0',
|
|
'fi',
|
|
// Why the env var rather than a stdin capture: jcode hands the hook its payload as
|
|
// a ready JSON object (capped at 16 KB), so Orca forwards it verbatim instead of
|
|
// hand-building JSON in shell, which is unsafe for arbitrary text.
|
|
'payload="$JCODE_HOOK_PAYLOAD"',
|
|
'orca_post_jcode_event() {',
|
|
...buildPosixAgentHookPostCommand('jcode').map((line) => ` ${line}`),
|
|
'}',
|
|
// Why detached on the gate: jcode reads this script's stderr to EOF before it
|
|
// releases the tool call, so an inherited pipe would hold the tool open for as long
|
|
// as the POST ran. Orca observes the tool live and adds no latency; the gate always
|
|
// allows, because Orca never blocks a jcode tool.
|
|
'if [ "$JCODE_HOOK_EVENT" = pre_tool ]; then',
|
|
' orca_post_jcode_event >/dev/null 2>&1 &',
|
|
'else',
|
|
' orca_post_jcode_event >/dev/null 2>&1 || :',
|
|
'fi',
|
|
'exit 0',
|
|
''
|
|
].join('\n')
|
|
}
|
|
|
|
export class JcodeHookService {
|
|
getStatus(): AgentHookInstallStatus {
|
|
const configPath = getJcodeConfigPath()
|
|
const scriptPath = getJcodeManagedScriptPath()
|
|
const table = readJcodeHooksTable(configPath)
|
|
if (table === null) {
|
|
return {
|
|
agent: 'jcode',
|
|
state: 'error',
|
|
configPath,
|
|
managedHooksPresent: false,
|
|
detail: 'Could not parse jcode config.toml [hooks] table'
|
|
}
|
|
}
|
|
const scriptPresent = existsSync(scriptPath)
|
|
const managedCommand = getJcodeManagedCommand(scriptPath)
|
|
const isManaged = createManagedCommandMatcher(getJcodeManagedScriptFileName())
|
|
const missing: string[] = []
|
|
const outdated: string[] = []
|
|
const userOwned: string[] = []
|
|
let managedCount = 0
|
|
for (const event of JCODE_HOOK_EVENTS) {
|
|
const value = table[event]
|
|
if (value === managedCommand) {
|
|
managedCount += 1
|
|
} else if (isManaged(value)) {
|
|
outdated.push(event)
|
|
} else if (value === undefined) {
|
|
missing.push(event)
|
|
} else {
|
|
userOwned.push(event)
|
|
}
|
|
}
|
|
const hasManagedEntries = managedCount > 0 || outdated.length > 0
|
|
const managedHooksPresent = hasManagedEntries || scriptPresent
|
|
let state: AgentHookInstallState
|
|
let detail: string | null
|
|
if (managedCount === JCODE_HOOK_EVENTS.length && scriptPresent) {
|
|
state = 'installed'
|
|
detail = null
|
|
} else if (!hasManagedEntries && missing.length === JCODE_HOOK_EVENTS.length) {
|
|
state = 'not_installed'
|
|
detail = null
|
|
} else {
|
|
state = 'partial'
|
|
const parts: string[] = []
|
|
if (hasManagedEntries && !scriptPresent) {
|
|
parts.push('Managed hook script missing')
|
|
}
|
|
if (outdated.length > 0) {
|
|
parts.push(`Managed hook command outdated for events: ${outdated.join(', ')}`)
|
|
}
|
|
if (missing.length > 0) {
|
|
parts.push(`Managed hook missing for events: ${missing.join(', ')}`)
|
|
}
|
|
if (userOwned.length > 0) {
|
|
parts.push(`User-owned hooks kept for events: ${userOwned.join(', ')}`)
|
|
}
|
|
detail = parts.join('; ')
|
|
}
|
|
return { agent: 'jcode', state, configPath, managedHooksPresent, detail }
|
|
}
|
|
|
|
install(): AgentHookInstallStatus {
|
|
const configPath = getJcodeConfigPath()
|
|
const scriptPath = getJcodeManagedScriptPath()
|
|
const table = readJcodeHooksTable(configPath)
|
|
if (table === null) {
|
|
return {
|
|
agent: 'jcode',
|
|
state: 'error',
|
|
configPath,
|
|
managedHooksPresent: false,
|
|
detail: 'Could not parse jcode config.toml [hooks] table'
|
|
}
|
|
}
|
|
writeManagedScript(scriptPath, getManagedScript())
|
|
const content = readConfigContent(configPath)
|
|
const edited = applyJcodeManagedHooks(
|
|
content,
|
|
JCODE_HOOK_EVENTS,
|
|
getJcodeManagedCommand(scriptPath),
|
|
getJcodeManagedScriptFileName()
|
|
)
|
|
writeConfigContent(configPath, edited.content)
|
|
return this.getStatus()
|
|
}
|
|
|
|
// Why: jcode invokes the script path recorded in its own config.toml, so an Orca
|
|
// upgrade that changes the script body must rewrite the file the user already has.
|
|
async refreshManagedScripts(): Promise<void> {
|
|
await refreshManagedScriptIfPresent(getJcodeManagedScriptPath(), getManagedScript())
|
|
}
|
|
|
|
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
|
// Why: remote-Windows is out of scope for v1 (same as Devin); assume POSIX.
|
|
const remoteConfigPath = getJcodeRemoteConfigPath(remoteHome)
|
|
const remoteScriptFileName = getJcodePosixManagedScriptFileName()
|
|
const remoteScriptPath = `${remoteHome.replace(/\/+$/, '')}/.orca/agent-hooks/${remoteScriptFileName}`
|
|
try {
|
|
const body = await readTextFileRemote(sftp, remoteConfigPath)
|
|
const content = body === null ? '' : body
|
|
const parsed = parseJcodeHooksTable(content)
|
|
if (parsed === null) {
|
|
return {
|
|
agent: 'jcode',
|
|
state: 'error',
|
|
configPath: remoteConfigPath,
|
|
managedHooksPresent: false,
|
|
detail: 'Could not parse remote jcode config.toml [hooks] table'
|
|
}
|
|
}
|
|
// Why: write script before settings so a mid-install failure never leaves config referencing a missing script.
|
|
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
|
const edited = applyJcodeManagedHooks(
|
|
content,
|
|
JCODE_HOOK_EVENTS,
|
|
getJcodeRemoteManagedCommand(remoteScriptPath),
|
|
remoteScriptFileName
|
|
)
|
|
await writeTextFileRemoteAtomic(sftp, remoteConfigPath, edited.content)
|
|
return {
|
|
agent: 'jcode',
|
|
state: edited.userOwnedEvents.length > 0 ? 'partial' : 'installed',
|
|
configPath: remoteConfigPath,
|
|
managedHooksPresent: true,
|
|
detail:
|
|
edited.userOwnedEvents.length > 0
|
|
? `User-owned hooks kept for events: ${edited.userOwnedEvents.join(', ')}`
|
|
: null
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
agent: 'jcode',
|
|
state: 'error',
|
|
configPath: remoteConfigPath,
|
|
managedHooksPresent: false,
|
|
detail: error instanceof Error ? error.message : String(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
remove(): AgentHookInstallStatus {
|
|
const configPath = getJcodeConfigPath()
|
|
const content = readConfigContent(configPath)
|
|
const removed = removeJcodeManagedHooks(content, getJcodeManagedScriptFileName())
|
|
if (removed.changed) {
|
|
writeConfigContent(configPath, removed.content)
|
|
}
|
|
return this.getStatus()
|
|
}
|
|
}
|
|
|
|
export const jcodeHookService = new JcodeHookService()
|
|
|
|
function readJcodeHooksTable(configPath: string): Record<string, string> | null {
|
|
if (!existsSync(configPath)) {
|
|
return {}
|
|
}
|
|
try {
|
|
return parseJcodeHooksTable(readFileSync(configPath, 'utf-8'))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function readConfigContent(configPath: string): string {
|
|
try {
|
|
return existsSync(configPath) ? readFileSync(configPath, 'utf-8') : ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function writeConfigContent(configPath: string, content: string): void {
|
|
// Why: skip the write when the on-disk content is already identical (same
|
|
// no-op guard as writeHooksJson) so repeated install() calls stay inert.
|
|
if (readConfigContent(configPath) === content) {
|
|
return
|
|
}
|
|
mkdirSync(dirname(configPath), { recursive: true })
|
|
writeFileSync(configPath, content, 'utf-8')
|
|
}
|