mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
feat(jcode): evidence-backed status pipeline (turn_start, live pre_tool, questions)
Co-authored-by: czzczz <chanzrz_zbf@foxmail.com>
This commit is contained in:
committed by
Neil
co-authored by
czzczz
parent
4049e63714
commit
2ca85d2ba2
@@ -84,6 +84,41 @@ session_start = "~/bin/mine"
|
||||
expect(result.content).toContain('session_start = "~/bin/mine"')
|
||||
})
|
||||
|
||||
it('keeps every table declared after [hooks] when removing managed entries', () => {
|
||||
// Why: `remote` rebuilds the file from the lines it keeps, so an early exit at
|
||||
// the next table header silently truncated the rest of a user's config.
|
||||
const source = `[hooks]
|
||||
turn_end = ${tomlQuoteString(MANAGED_COMMAND)}
|
||||
pre_tool_timeout_ms = 5000
|
||||
|
||||
[terminal]
|
||||
preferred = "ghostty"
|
||||
|
||||
[ui]
|
||||
theme = "dark"
|
||||
`
|
||||
const result = removeJcodeManagedHooks(source, 'jcode-hook.sh')
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.content).not.toContain(MANAGED_COMMAND)
|
||||
expect(result.content).toContain('pre_tool_timeout_ms = 5000')
|
||||
expect(result.content).toContain('[terminal]')
|
||||
expect(result.content).toContain('preferred = "ghostty"')
|
||||
expect(result.content).toContain('[ui]')
|
||||
expect(result.content).toContain('theme = "dark"')
|
||||
})
|
||||
|
||||
it('does not touch a managed-looking command outside the [hooks] table', () => {
|
||||
const source = `[terminal]
|
||||
spawn_hook = ${tomlQuoteString(MANAGED_COMMAND)}
|
||||
|
||||
[hooks]
|
||||
turn_end = ${tomlQuoteString(MANAGED_COMMAND)}
|
||||
`
|
||||
const result = removeJcodeManagedHooks(source, 'jcode-hook.sh')
|
||||
expect(result.content).toContain(`spawn_hook = ${tomlQuoteString(MANAGED_COMMAND)}`)
|
||||
expect(result.content).not.toContain(`turn_end = ${tomlQuoteString(MANAGED_COMMAND)}`)
|
||||
})
|
||||
|
||||
it('keeps CRLF line endings when editing a Windows-owned config', () => {
|
||||
const source = '[hooks]\r\nturn_end = "~/bin/mine"\r\n'
|
||||
const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh')
|
||||
|
||||
@@ -178,12 +178,10 @@ export function removeJcodeManagedHooks(
|
||||
}
|
||||
const header = getTomlTableHeader(line)
|
||||
if (header) {
|
||||
if (inHooksTable) {
|
||||
break
|
||||
}
|
||||
if (parseTomlTablePath(header)?.join('.') === 'hooks') {
|
||||
inHooksTable = true
|
||||
}
|
||||
// Why: leaving the table stops the removal, but the rest of the file must
|
||||
// still be copied out — `kept` is the whole result, so breaking here once
|
||||
// truncated every table declared after [hooks].
|
||||
inHooksTable = parseTomlTablePath(header)?.join('.') === 'hooks'
|
||||
kept.push(line)
|
||||
state = updateTomlLineScanState(state, line)
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createServer, type Server } from 'node:net'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() }))
|
||||
vi.mock('os', async () => {
|
||||
const actual = (await vi.importActual('os')) as Record<string, unknown>
|
||||
return { ...actual, homedir: homedirMock }
|
||||
})
|
||||
|
||||
import { JcodeHookService } from './hook-service'
|
||||
import { getJcodeManagedScriptPath } from './hook-settings'
|
||||
|
||||
/** Installs the managed hook into a throwaway home and returns the script path. */
|
||||
function installManagedScript(): { scriptPath: string; cleanup: () => void } {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'orca-jcode-gate-'))
|
||||
homedirMock.mockReturnValue(homeDir)
|
||||
vi.stubEnv('JCODE_HOME', join(homeDir, '.jcode'))
|
||||
new JcodeHookService().install()
|
||||
const scriptPath = getJcodeManagedScriptPath()
|
||||
return {
|
||||
scriptPath,
|
||||
cleanup: () => {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe.runIf(process.platform !== 'win32')('jcode managed hook as jcode runs it', () => {
|
||||
it('returns immediately on pre_tool even when the hook server never answers', async () => {
|
||||
const { scriptPath, cleanup } = installManagedScript()
|
||||
// A server that accepts the connection and then never replies: curl holds it
|
||||
// open until its own --max-time 1.5, which is what a synchronous gate would
|
||||
// hand straight to the agent on every single tool call.
|
||||
const blackHole: Server = createServer(() => {})
|
||||
await new Promise<void>((resolve) => blackHole.listen(0, '127.0.0.1', resolve))
|
||||
const address = blackHole.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
const endpointDir = mkdtempSync(join(tmpdir(), 'orca-jcode-endpoint-'))
|
||||
try {
|
||||
const endpoint = join(endpointDir, 'endpoint.sh')
|
||||
writeFileSync(
|
||||
endpoint,
|
||||
`ORCA_AGENT_HOOK_PORT=${port}\nORCA_AGENT_HOOK_TOKEN=t\nexport ORCA_AGENT_HOOK_PORT ORCA_AGENT_HOOK_TOKEN\n`
|
||||
)
|
||||
|
||||
// A tool input far larger than a 64 KB pipe buffer: jcode write_all()s this
|
||||
// to the gate's stdin and awaits it, so a gate that never reads stdin stalls.
|
||||
const bigToolInput = JSON.stringify({ content: 'x'.repeat(512 * 1024) })
|
||||
const startedAt = Date.now()
|
||||
execFileSync('/bin/sh', [scriptPath], {
|
||||
input: bigToolInput,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_AGENT_HOOK_ENDPOINT: endpoint,
|
||||
ORCA_PANE_KEY: 'tab-1:leaf-1',
|
||||
JCODE_HOOK_EVENT: 'pre_tool',
|
||||
JCODE_HOOK_SESSION_ID: 'session_gate_1',
|
||||
JCODE_HOOK_PAYLOAD: JSON.stringify({ event: 'pre_tool', tool_name: 'write' })
|
||||
},
|
||||
// Why: the assertion below is the real gate; this only stops a regression
|
||||
// from hanging the suite instead of failing it.
|
||||
timeout: 20_000,
|
||||
// stdio is the point of the test: jcode reads stderr to EOF, so an
|
||||
// inherited pipe in a backgrounded child would hold the gate open for as
|
||||
// long as the POST ran, detached or not.
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
const elapsed = Date.now() - startedAt
|
||||
|
||||
// Comfortably under curl's 1.5s ceiling: a synchronous POST would sit on
|
||||
// that ceiling for every tool call, and jcode's own budget is only 5s.
|
||||
expect(elapsed).toBeLessThan(1_000)
|
||||
} finally {
|
||||
rmSync(endpointDir, { recursive: true, force: true })
|
||||
await new Promise<void>((resolve) => blackHole.close(() => resolve()))
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('drains the gate stdin before exiting on a missing Orca environment', () => {
|
||||
const { scriptPath, cleanup } = installManagedScript()
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
execFileSync('/bin/sh', [scriptPath], {
|
||||
input: JSON.stringify({ content: 'y'.repeat(512 * 1024) }),
|
||||
// No ORCA_PANE_KEY: the script exits early, but only after taking stdin.
|
||||
env: { ...process.env, JCODE_HOOK_EVENT: 'pre_tool', ORCA_PANE_KEY: '' },
|
||||
timeout: 20_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('posts synchronously for observer events, which jcode never waits on', () => {
|
||||
const { scriptPath, cleanup } = installManagedScript()
|
||||
try {
|
||||
const script = readFileSync(scriptPath, 'utf8')
|
||||
const gateBranch = script.slice(script.indexOf('if [ "$JCODE_HOOK_EVENT" = pre_tool ]'))
|
||||
expect(gateBranch).toContain('orca_post_jcode_event >/dev/null 2>&1 &')
|
||||
// The observer path keeps the plain call, so a slow POST cannot be lost to
|
||||
// a script that exited first.
|
||||
expect(script.trimEnd().endsWith('exit 0')).toBe(true)
|
||||
expect(script).toContain('\norca_post_jcode_event\n')
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe.runIf(process.platform !== 'win32')('managed script shape', () => {
|
||||
it('writes an executable script jcode can exec directly', () => {
|
||||
const { scriptPath, cleanup } = installManagedScript()
|
||||
try {
|
||||
mkdirSync(dirname(scriptPath), { recursive: true })
|
||||
chmodSync(scriptPath, 0o755)
|
||||
const script = readFileSync(scriptPath, 'utf8')
|
||||
// Why: jcode parses the command shell-style but executes it directly, so the
|
||||
// file itself must carry the interpreter.
|
||||
expect(script.startsWith('#!/bin/sh\n')).toBe(true)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,11 @@ vi.mock('os', async () => {
|
||||
})
|
||||
|
||||
import { JcodeHookService } from './hook-service'
|
||||
import { getJcodeConfigPath, getJcodeManagedScriptPath } from './hook-settings'
|
||||
import {
|
||||
getJcodeConfigPath,
|
||||
getJcodeManagedScriptPath,
|
||||
JCODE_HOOK_EVENTS
|
||||
} from './hook-settings'
|
||||
import { tomlQuoteString } from './hook-config'
|
||||
|
||||
describe('JcodeHookService', () => {
|
||||
@@ -50,7 +54,7 @@ describe('JcodeHookService', () => {
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
|
||||
const config = readFileSync(getJcodeConfigPath(), 'utf8')
|
||||
for (const event of ['turn_end', 'session_start', 'session_end', 'post_tool']) {
|
||||
for (const event of JCODE_HOOK_EVENTS) {
|
||||
// Why: tomlQuoteString doubles backslashes, so the serialized value (not
|
||||
// the raw path) is what appears in the config.
|
||||
expect(config).toContain(`${event} = ${tomlQuoteString(getJcodeManagedScriptPath())}`)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildWindowsAgentHookPostCommand,
|
||||
writeManagedScript
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
readTextFileRemote,
|
||||
writeManagedScriptRemote,
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
POSIX_HOOK_STDIN_DRAIN_COMMAND,
|
||||
WINDOWS_HOOK_STDIN_DRAIN_COMMAND
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import {
|
||||
applyJcodeManagedHooks,
|
||||
@@ -37,6 +40,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// 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',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
// Why: pre_tool is jcode's gate — it writes the tool input to our stdin and
|
||||
// waits for us. Drain it first so a tool input larger than the pipe buffer
|
||||
// can never stall the agent mid-write.
|
||||
`if "%JCODE_HOOK_EVENT%"=="pre_tool" ${WINDOWS_HOOK_STDIN_DRAIN_COMMAND}`,
|
||||
buildWindowsAgentHookPostCommand('jcode'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
@@ -51,6 +58,12 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
// Why: pre_tool is jcode's gate. It writes the tool input to our stdin and
|
||||
// waits for us, so drain stdin before any exit — a tool input larger than
|
||||
// the pipe buffer would otherwise stall 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',
|
||||
@@ -58,20 +71,30 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// at 16 KB), so Orca forwards it verbatim instead of hand-building JSON in
|
||||
// shell (unsafe for arbitrary text). The event name is also posted as a
|
||||
// top-level form field for old payloads that omit it.
|
||||
'printf \'%s\' "$JCODE_HOOK_PAYLOAD" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/jcode" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "hook_event_name=${JCODE_HOOK_EVENT}" \\',
|
||||
' --data-urlencode "session_id=${JCODE_HOOK_SESSION_ID}" \\',
|
||||
' --data-urlencode "cwd=${JCODE_HOOK_CWD}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'orca_post_jcode_event() {',
|
||||
' printf \'%s\' "$JCODE_HOOK_PAYLOAD" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/jcode" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "hook_event_name=${JCODE_HOOK_EVENT}" \\',
|
||||
' --data-urlencode "session_id=${JCODE_HOOK_SESSION_ID}" \\',
|
||||
' --data-urlencode "cwd=${JCODE_HOOK_CWD}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'}',
|
||||
// Why: jcode reads this gate's stderr to EOF before releasing the tool call, so
|
||||
// the POST runs detached with both pipes closed. Orca observes the tool live and
|
||||
// adds no latency; the gate always allows (Orca never blocks a jcode tool).
|
||||
'if [ "$JCODE_HOOK_EVENT" = pre_tool ]; then',
|
||||
' orca_post_jcode_event >/dev/null 2>&1 &',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'orca_post_jcode_event',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
@@ -154,6 +177,12 @@ export class JcodeHookService {
|
||||
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)
|
||||
|
||||
@@ -6,11 +6,27 @@ import { join } from 'node:path'
|
||||
|
||||
const JCODE_SCRIPT_BASE = 'jcode-hook'
|
||||
|
||||
// Why: only observer hooks. pre_tool is a synchronous gate that would add
|
||||
// startup latency to every tool call without gating anything for Orca.
|
||||
export const JCODE_HOOK_EVENTS = ['turn_end', 'session_start', 'session_end', 'post_tool'] as const
|
||||
// The lifecycle points Orca subscribes to, in the order jcode fires them.
|
||||
// `pre_tool` is jcode's synchronous gate, but the managed script backgrounds its
|
||||
// POST and exits 0 immediately, so Orca observes the tool without ever holding
|
||||
// up a tool call. Without it a long `bash` would show no tool at all until it
|
||||
// finished, and `request_permission` (the only jcode tool a human answers)
|
||||
// would only be seen after the answer.
|
||||
export const JCODE_HOOK_EVENTS = [
|
||||
'session_start',
|
||||
'turn_start',
|
||||
'pre_tool',
|
||||
'post_tool',
|
||||
'turn_end',
|
||||
'session_end'
|
||||
] as const
|
||||
export type JcodeHookEvent = (typeof JCODE_HOOK_EVENTS)[number]
|
||||
|
||||
/** jcode waits for this one; the managed script must never block on it. */
|
||||
export function isJcodeGateHookEvent(event: JcodeHookEvent): boolean {
|
||||
return event === 'pre_tool'
|
||||
}
|
||||
|
||||
export function getJcodeConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const explicit = env.JCODE_HOME?.trim()
|
||||
return explicit ? join(explicit, 'config.toml') : join(homedir(), '.jcode', 'config.toml')
|
||||
|
||||
Reference in New Issue
Block a user