mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
feat(agent-hooks): shared listener + relay adapter (PR 1/N for SSH agent status) (#1678)
* feat(agent-hooks): introduce relay wire envelope + connectionId stamping Adds the shared `agent-hook-relay.ts` module with the `agent.hook` JSON-RPC notification envelope, the `agent_hook.requestReplay` / `agent_hook.installPlugins` method names, and the `ORCA_FEATURE_REMOTE_AGENT_HOOKS` flag helper. Promotes `AgentHookSource` to `shared/` so the relay can import it without dragging Electron in. Threads a `connectionId: string | null` field through `AgentHookEventPayload`, the `agentStatus:set` IPC contract, and the renderer-bound preload listener. Local hook posts stamp `null`; the relay-forwarded path will stamp from `mux` identity in a later commit. Renderer uses the stamp for stale-event filtering when an SSH connection tears down with notifications still in flight. See docs/design/agent-status-over-ssh.md §1, §5, §8 (commit #1). Co-authored-by: Orca <help@stably.ai> * refactor(agent-hooks): extract shared listener; add relay-side adapter Extracts the listener internals (request parsing, payload normalization, endpoint-file writing, per-CLI extractors, warn-once Sets, slowloris timer helper, request size cap, paneKey caches) from `src/main/agent-hooks/server.ts` into a new transport-agnostic `src/shared/agent-hook-listener.ts`. The shared module uses only Node builtins (no Electron) so it is safe to import from `src/relay/`. Adds `src/relay/agent-hook-server.ts` — a thin HTTP-loopback adapter that wires the shared listener to a `forward(envelope)` callback so `relay.ts` can re-emit each parsed payload as an `agent.hook` JSON-RPC notification on the existing SshChannelMultiplexer. The adapter owns: - 127.0.0.1:0 socket + bearer-token auth, identical shape to the local server - per-paneKey last-payload cache + replayCachedPayloadsForPanes() for the request-driven replay path used after `--connect` reattach (see §5 Path 3) - clearPaneState(paneKey) for PTY-exit eviction (symmetric with local server) - buildPtyEnv() / endpoint-file writing for relay-spawned PTYs Orca's `AgentHookServer` is now a ~200-LoC adapter over the shared listener that owns the IPC fanout, listener replay, and `ingestRemote(envelope, connId)` entry point that bypasses the HTTP path for relay-forwarded events. See docs/design/agent-status-over-ssh.md §3, §8 (commit #2). Co-authored-by: Orca <help@stably.ai> * fix(preload): expose connectionId on agentStatus.onSet type src/preload/index.ts already passes through `connectionId?: string | null` from main, but the PreloadApi declaration in api-types.ts was missing the field. Align the type with the runtime contract so renderer call sites can read connectionId without an `as` cast. Co-authored-by: Orca <help@stably.ai> * fix(agent-hooks): harden ingestRemote + relay replay; review-driven cleanup - ingestRemote: re-run normalizeAgentStatusPayload at trust boundary; trim+validate connectionId/paneKey/tabId/worktreeId - relay: preserve source/env/version through replay via sidecar map; drop sourceFromAgentType fallback that mis-tagged unknown agents - shared listener: exhaustive switch+never on AgentHookSource dispatch chains; extractPromptText returns trimmed values; export MAX_PANE_KEY_LEN - preload: tighten connectionId from optional to required (always sent) - main IPC: reorder spread so explicit envelope fields win on collision Co-authored-by: Orca <help@stably.ai> * chore(docs): drop agent-status-over-ssh design doc from PR The design RFC was useful for authoring this PR series but doesn't belong in-tree — keeping it here would freeze line-number references and design prose against future churn. Folding it into the PR description instead. Co-authored-by: Orca <help@stably.ai> * chore(agent-hooks): widen ingestRemote type for env/version (PR2 prep) Declares `env?: string` and `version?: string` on the `ingestRemote` envelope parameter so PR2 only needs to add the `warnOnHookEnvOrVersionMismatch` callsite, not also widen the type. The fields are forwarded verbatim from the agent CLI POST body on the remote and let Orca's warn-once cross-build / dev-vs-prod diagnostics fire identically on remote-sourced events. Type-only addition; no runtime consumer in this PR. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { AgentHookServer, _internals } from './server'
|
||||
import { parseAgentStatusPayload } from '../../shared/agent-status-types'
|
||||
|
||||
const PANE = 'tab-1:0'
|
||||
|
||||
@@ -77,6 +78,7 @@ describe('AgentHookServer listener replay', () => {
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: null,
|
||||
payload: expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'replay me',
|
||||
@@ -151,6 +153,7 @@ describe('AgentHookServer listener replay', () => {
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo::/tmp/worktree with "quotes"',
|
||||
connectionId: null,
|
||||
payload: expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'form encoded',
|
||||
@@ -1202,3 +1205,157 @@ describe('Endpoint file lifecycle', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentHookServer ingestRemote', () => {
|
||||
it('stamps connectionId and forwards a valid relay envelope to the listener', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload },
|
||||
'conn-1'
|
||||
)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'conn-1',
|
||||
payload
|
||||
})
|
||||
})
|
||||
|
||||
it('drops envelopes whose payload state is not in AGENT_STATUS_STATES', () => {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
// Why: bypass parseAgentStatusPayload (which itself rejects bad states) by
|
||||
// constructing an obviously-invalid payload — `ingestRemote` is the trust
|
||||
// boundary we're testing, not the parser.
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'nonsense', prompt: '', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops envelopes whose paneKey exceeds MAX_PANE_KEY_LEN', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
// 201 chars — one past the listener's 200-char cap.
|
||||
const oversized = 'a'.repeat(201)
|
||||
server.ingestRemote(
|
||||
{ paneKey: oversized, tabId: 'tab-1', worktreeId: 'wt-1', payload },
|
||||
'conn-1'
|
||||
)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects empty connectionId', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload },
|
||||
''
|
||||
)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects whitespace-only connectionId', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload },
|
||||
' '
|
||||
)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects non-string tabId', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, tabId: 123 as unknown as string, worktreeId: 'wt-1', payload },
|
||||
'conn-1'
|
||||
)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects empty paneKey after trim', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{ paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload },
|
||||
'conn-1'
|
||||
)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => {
|
||||
// Why: the relay normally normalizes the payload on the wire, but a buggy
|
||||
// or malicious relay could forward an over-cap field. ingestRemote must
|
||||
// re-run the canonical normalizer so the AGENT_STATUS_MAX_FIELD_LENGTH
|
||||
// cap (200 chars) is enforced at the trust boundary.
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'x'.repeat(500), agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
const event = listener.mock.calls[0][0] as { payload: { prompt: string } }
|
||||
expect(event.payload.prompt.length).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
+154
-1384
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -276,15 +276,16 @@ function openMainWindow(): BrowserWindow {
|
||||
}
|
||||
})
|
||||
mainWindow = window
|
||||
agentHookServer.setListener(({ paneKey, tabId, worktreeId, payload }) => {
|
||||
agentHookServer.setListener(({ paneKey, tabId, worktreeId, connectionId, payload }) => {
|
||||
if (mainWindow?.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
mainWindow?.webContents.send('agentStatus:set', {
|
||||
...payload,
|
||||
paneKey,
|
||||
tabId,
|
||||
worktreeId,
|
||||
...payload
|
||||
connectionId
|
||||
})
|
||||
// Why: cursor-agent's OSC title stays "Cursor Agent" for the whole turn,
|
||||
// and opencode's stays bare "OpenCode" — neither carries a working/idle
|
||||
|
||||
@@ -1236,6 +1236,11 @@ export type PreloadApi = {
|
||||
paneKey: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
// Why: stamped by main from the SshChannelMultiplexer the event
|
||||
// arrived on (or null for local). The renderer uses it to drop
|
||||
// in-flight events when an SSH connection tears down — see
|
||||
// docs/design/agent-status-over-ssh.md §5.
|
||||
connectionId: string | null
|
||||
state: AgentStatusState
|
||||
prompt?: string
|
||||
agentType?: string
|
||||
|
||||
@@ -2303,6 +2303,11 @@ const api = {
|
||||
paneKey: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
// Why: stamped by main from the SshChannelMultiplexer the event
|
||||
// arrived on (or null for local). The renderer uses it to drop
|
||||
// in-flight events when an SSH connection tears down — see
|
||||
// docs/design/agent-status-over-ssh.md §5.
|
||||
connectionId: string | null
|
||||
state: AgentStatusState
|
||||
prompt?: string
|
||||
agentType?: string
|
||||
@@ -2318,6 +2323,7 @@ const api = {
|
||||
paneKey: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
connectionId: string | null
|
||||
state: AgentStatusState
|
||||
prompt?: string
|
||||
agentType?: string
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { RelayAgentHookServer } from './agent-hook-server'
|
||||
import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay'
|
||||
|
||||
describe('RelayAgentHookServer', () => {
|
||||
let dir: string
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'relay-hook-server-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => {
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const { port, token } = server.getCoordinates()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: 'tab-1:0',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
env: 'remote',
|
||||
version: '1',
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' }
|
||||
})
|
||||
})
|
||||
expect(res.status).toBe(204)
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
const envelope = forward.mock.calls[0][0]
|
||||
expect(envelope.source).toBe('claude')
|
||||
expect(envelope.paneKey).toBe('tab-1:0')
|
||||
expect(envelope.tabId).toBe('tab-1')
|
||||
expect(envelope.connectionId).toBeNull()
|
||||
expect(envelope.payload.state).toBe('working')
|
||||
expect(envelope.payload.prompt).toBe('hi')
|
||||
// Why: the relay forwards body env/version verbatim so Orca's existing
|
||||
// warn-once cross-build / dev-vs-prod diagnostics still fire on remote.
|
||||
expect(envelope.env).toBe('remote')
|
||||
expect(envelope.version).toBe('1')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects requests with the wrong bearer token (403)', async () => {
|
||||
const forward = vi.fn()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const { port } = server.getCoordinates()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': 'wrong'
|
||||
},
|
||||
body: '{}'
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('replays cached payloads on demand', async () => {
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const { port, token } = server.getCoordinates()
|
||||
await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: 'tab-1:0',
|
||||
tabId: 'tab-1',
|
||||
env: 'remote',
|
||||
version: '1',
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'cache me' }
|
||||
})
|
||||
})
|
||||
forward.mockClear()
|
||||
const replayed = server.replayCachedPayloadsForPanes()
|
||||
expect(replayed).toBe(1)
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
expect(forward.mock.calls[0][0].payload.prompt).toBe('cache me')
|
||||
// Why: replay must preserve the wire envelope's env/version (and source)
|
||||
// so Orca's warn-once cross-build / dev-vs-prod diagnostics fire on
|
||||
// replayed events the same as on live POST events.
|
||||
expect(forward.mock.calls[0][0].source).toBe('claude')
|
||||
expect(forward.mock.calls[0][0].env).toBe('remote')
|
||||
expect(forward.mock.calls[0][0].version).toBe('1')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not replay paneKeys after clearPaneState', async () => {
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const { port, token } = server.getCoordinates()
|
||||
await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: 'tab-1:0',
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'gone' }
|
||||
})
|
||||
})
|
||||
server.clearPaneState('tab-1:0')
|
||||
forward.mockClear()
|
||||
const replayed = server.replayCachedPayloadsForPanes()
|
||||
expect(replayed).toBe(0)
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes ORCA_AGENT_HOOK_* env vars after start', async () => {
|
||||
const forward = vi.fn()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
expect(env.ORCA_AGENT_HOOK_PORT).toMatch(/^\d+$/)
|
||||
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy()
|
||||
expect(env.ORCA_AGENT_HOOK_ENV).toBe('remote')
|
||||
expect(env.ORCA_AGENT_HOOK_VERSION).toBe('1')
|
||||
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
// Why: relay-side adapter for the shared agent-hook listener pipeline. Hosts
|
||||
// a loopback HTTP server (same shape as Orca's main-process server: bind
|
||||
// 127.0.0.1:0, bearer-token auth, /hook/<source> routing) and forwards every
|
||||
// parsed payload via a callback so `relay.ts` can re-emit it as an
|
||||
// `agent.hook` JSON-RPC notification across the existing SSH channel.
|
||||
//
|
||||
// Per-instance state (warn-once Sets, last-status cache, last-prompt /
|
||||
// last-tool caches) lives on `HookListenerState`. The cache is bounded to one
|
||||
// entry per paneKey — see docs/design/agent-status-over-ssh.md §5 (Path 3,
|
||||
// request-driven replay) for the rationale.
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types'
|
||||
import {
|
||||
clearAllListenerCaches,
|
||||
clearPaneCacheState,
|
||||
createHookListenerState,
|
||||
getEndpointFileName,
|
||||
HOOK_REQUEST_SLOWLORIS_MS,
|
||||
normalizeHookPayload,
|
||||
readRequestBody,
|
||||
resolveHookSource,
|
||||
writeEndpointFile,
|
||||
type AgentHookEventPayload,
|
||||
type HookListenerState
|
||||
} from '../shared/agent-hook-listener'
|
||||
import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay'
|
||||
|
||||
export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void
|
||||
|
||||
// Why: relay's userData equivalent. Lives under $HOME so each user on a
|
||||
// shared dev box gets their own dir, owned 0o700. Mirrors RELAY_REMOTE_DIR
|
||||
// from `ssh-relay-deploy.ts` but stays local to this module — the hook
|
||||
// server is the only consumer.
|
||||
const RELAY_HOOKS_DIR_NAME = '.orca-relay'
|
||||
const RELAY_HOOKS_SUBDIR = 'agent-hooks'
|
||||
|
||||
function defaultEndpointDir(): string {
|
||||
return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR)
|
||||
}
|
||||
|
||||
export type RelayHookServerOptions = {
|
||||
/** Where to put endpoint.env / endpoint.cmd. Defaults to `$HOME/.orca-relay/agent-hooks`. */
|
||||
endpointDir?: string
|
||||
/** Env tag forwarded into hook payloads (warn-once cross-build diagnostic).
|
||||
* Defaults to "remote" — distinct from Orca's local 'production'/'development'. */
|
||||
env?: string
|
||||
/** Called once per parsed payload. The relay wires this to
|
||||
* `dispatcher.notify('agent.hook', envelope)`. */
|
||||
forward: RelayHookForward
|
||||
}
|
||||
|
||||
export class RelayAgentHookServer {
|
||||
private server: ReturnType<typeof createServer> | null = null
|
||||
private port = 0
|
||||
private token = ''
|
||||
private env: string
|
||||
private endpointDir: string
|
||||
private endpointFilePath: string
|
||||
private endpointFileWritten = false
|
||||
private state: HookListenerState = createHookListenerState()
|
||||
// Why: the shared `HookListenerState.lastStatusByPaneKey` cache only stores
|
||||
// `AgentHookEventPayload` (no wire-envelope fields). Replay must still emit
|
||||
// the original `source`/`env`/`version` so Orca's warn-once diagnostics fire
|
||||
// identically to the live POST path. Keep this as a per-instance sidecar map
|
||||
// so the shared listener type stays unchanged. Invariant: every key present
|
||||
// in `state.lastStatusByPaneKey` must also be present here — populated and
|
||||
// cleared in lockstep on the live POST path, clearPaneState, and stop().
|
||||
private lastEnvelopeMetaByPaneKey: Map<
|
||||
string,
|
||||
{ source: AgentHookSource; env?: string; version?: string }
|
||||
> = new Map()
|
||||
private forward: RelayHookForward
|
||||
|
||||
constructor(options: RelayHookServerOptions) {
|
||||
this.env = options.env ?? 'remote'
|
||||
this.endpointDir = options.endpointDir ?? defaultEndpointDir()
|
||||
this.endpointFilePath = join(this.endpointDir, getEndpointFileName())
|
||||
this.forward = options.forward
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.server) {
|
||||
return
|
||||
}
|
||||
this.token = randomUUID()
|
||||
this.endpointFileWritten = false
|
||||
this.server = createServer((req, res) => this.handleRequest(req, res))
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onStartupError = (err: Error): void => {
|
||||
this.server?.off('listening', onListening)
|
||||
reject(err)
|
||||
}
|
||||
const onListening = (): void => {
|
||||
this.server?.off('error', onStartupError)
|
||||
this.server?.on('error', (err) => {
|
||||
process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`)
|
||||
})
|
||||
const address = this.server!.address()
|
||||
if (address && typeof address === 'object') {
|
||||
this.port = address.port
|
||||
}
|
||||
this.endpointFileWritten = writeEndpointFile(this.endpointDir, this.endpointFilePath, {
|
||||
port: this.port,
|
||||
token: this.token,
|
||||
env: this.env,
|
||||
version: ORCA_HOOK_PROTOCOL_VERSION
|
||||
})
|
||||
resolve()
|
||||
}
|
||||
this.server!.once('error', onStartupError)
|
||||
// Why: bind 127.0.0.1:0 so the OS assigns a free port. Loopback only —
|
||||
// the agent CLI inside the same remote box reaches us via curl
|
||||
// 127.0.0.1:PORT; nobody outside the box can.
|
||||
this.server!.listen(0, '127.0.0.1', onListening)
|
||||
})
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.server?.close()
|
||||
this.server = null
|
||||
this.port = 0
|
||||
this.token = ''
|
||||
this.endpointFileWritten = false
|
||||
clearAllListenerCaches(this.state)
|
||||
this.lastEnvelopeMetaByPaneKey.clear()
|
||||
}
|
||||
|
||||
/** Request-driven replay: walks the per-paneKey last-payload cache and
|
||||
* forwards each entry as a fresh notification. Called after Orca has
|
||||
* re-wired its `agent.hook` handler on the new mux post-`--connect`.
|
||||
* The relay-driver issues the replay forwards BEFORE returning from the
|
||||
* request handler so the response strictly trails all replayed
|
||||
* notifications on the dispatcher's single write callback. */
|
||||
replayCachedPayloadsForPanes(): number {
|
||||
let count = 0
|
||||
for (const [paneKey, event] of this.state.lastStatusByPaneKey.entries()) {
|
||||
const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey)
|
||||
// Why: invariant — every paneKey in the shared status cache is populated
|
||||
// in lockstep with `lastEnvelopeMetaByPaneKey`. If meta is missing,
|
||||
// something has drifted; skip rather than fall back to a guessed source
|
||||
// that would mis-tag the event downstream.
|
||||
if (!meta) {
|
||||
continue
|
||||
}
|
||||
this.forwardEvent(event, meta.source, meta.env, meta.version)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/** Drop a paneKey's cached entries on PTY exit so a terminated pane never
|
||||
* resurfaces as a ghost event on a later reconnect. Symmetric with the
|
||||
* local server's clearPaneState on PTY teardown. */
|
||||
clearPaneState(paneKey: string): void {
|
||||
clearPaneCacheState(this.state, paneKey)
|
||||
this.lastEnvelopeMetaByPaneKey.delete(paneKey)
|
||||
}
|
||||
|
||||
/** Env vars to inject into every relay-spawned PTY so the hook script /
|
||||
* in-process plugin POSTs to this loopback server. */
|
||||
buildPtyEnv(): Record<string, string> {
|
||||
if (this.port <= 0 || !this.token) {
|
||||
return {}
|
||||
}
|
||||
const env: Record<string, string> = {
|
||||
ORCA_AGENT_HOOK_PORT: String(this.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: this.token,
|
||||
ORCA_AGENT_HOOK_ENV: this.env,
|
||||
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
|
||||
}
|
||||
if (this.endpointFileWritten) {
|
||||
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePath
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Test-only / diagnostics accessor. */
|
||||
getCoordinates(): { port: number; token: string; endpointFilePath: string } {
|
||||
return { port: this.port, token: this.token, endpointFilePath: this.endpointFilePath }
|
||||
}
|
||||
|
||||
// ─── Private ──────────────────────────────────────────────────────
|
||||
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (req.headers['x-orca-agent-hook-token'] !== this.token) {
|
||||
res.writeHead(403)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => {
|
||||
req.destroy()
|
||||
})
|
||||
try {
|
||||
const body = await readRequestBody(req)
|
||||
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
||||
const source = resolveHookSource(pathname)
|
||||
if (!source) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const event = normalizeHookPayload(this.state, source, body, this.env)
|
||||
if (event) {
|
||||
this.state.lastStatusByPaneKey.set(event.paneKey, event)
|
||||
// TODO: once normalizeHookPayload returns validated env/version, drop
|
||||
// bodyEnv/bodyVersion and source those from the listener result instead.
|
||||
const env = this.bodyEnv(body)
|
||||
const version = this.bodyVersion(body)
|
||||
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
|
||||
this.forwardEvent(event, source, env, version)
|
||||
}
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} catch {
|
||||
// Why: agent hooks must fail open — return success on parse / size /
|
||||
// timeout errors so a buggy agent script never blocks the agent run.
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
private forwardEvent(
|
||||
event: AgentHookEventPayload,
|
||||
source: AgentHookSource,
|
||||
env?: string,
|
||||
version?: string
|
||||
): void {
|
||||
const envelope: AgentHookRelayEnvelope = {
|
||||
source,
|
||||
paneKey: event.paneKey,
|
||||
tabId: event.tabId,
|
||||
worktreeId: event.worktreeId,
|
||||
connectionId: null,
|
||||
env,
|
||||
version,
|
||||
payload: event.payload
|
||||
}
|
||||
this.forward(envelope)
|
||||
}
|
||||
|
||||
private bodyEnv(body: unknown): string | undefined {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return undefined
|
||||
}
|
||||
const v = (body as Record<string, unknown>).env
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
|
||||
private bodyVersion(body: unknown): string | undefined {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return undefined
|
||||
}
|
||||
const v = (body as Record<string, unknown>).version
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
createHookListenerState,
|
||||
getEndpointFileName,
|
||||
isShellSafeEndpointValue,
|
||||
normalizeHookPayload,
|
||||
parseFormEncodedBody,
|
||||
resolveHookSource,
|
||||
writeEndpointFile,
|
||||
type HookListenerState
|
||||
} from './agent-hook-listener'
|
||||
|
||||
describe('shared agent-hook-listener', () => {
|
||||
let state: HookListenerState
|
||||
|
||||
beforeEach(() => {
|
||||
state = createHookListenerState()
|
||||
})
|
||||
|
||||
it('parses form-encoded bodies', () => {
|
||||
const decoded = parseFormEncodedBody('paneKey=tab-1%3A0&worktreeId=foo')
|
||||
expect(decoded.paneKey).toBe('tab-1:0')
|
||||
expect(decoded.worktreeId).toBe('foo')
|
||||
})
|
||||
|
||||
it('routes pathnames to a known source or null', () => {
|
||||
expect(resolveHookSource('/hook/claude')).toBe('claude')
|
||||
expect(resolveHookSource('/hook/cursor')).toBe('cursor')
|
||||
expect(resolveHookSource('/hook/unknown')).toBeNull()
|
||||
expect(resolveHookSource('/')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects shell-unsafe endpoint values', () => {
|
||||
expect(isShellSafeEndpointValue('1234')).toBe(true)
|
||||
expect(isShellSafeEndpointValue('abc-DEF.0_1')).toBe(true)
|
||||
expect(isShellSafeEndpointValue('')).toBe(false)
|
||||
expect(isShellSafeEndpointValue('foo&bar')).toBe(false)
|
||||
expect(isShellSafeEndpointValue('foo bar')).toBe(false)
|
||||
expect(isShellSafeEndpointValue('foo;bar')).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes a Claude UserPromptSubmit body to a working state', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: 'tab-1:0',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt',
|
||||
env: 'production',
|
||||
version: '1',
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hello' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event).not.toBeNull()
|
||||
expect(event!.paneKey).toBe('tab-1:0')
|
||||
expect(event!.connectionId).toBeNull()
|
||||
expect(event!.payload.state).toBe('working')
|
||||
expect(event!.payload.prompt).toBe('hello')
|
||||
expect(event!.payload.agentType).toBe('claude')
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace from extracted prompt text', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: 'tab-1:0',
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: ' hi ' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event).not.toBeNull()
|
||||
expect(event!.payload.prompt).toBe('hi')
|
||||
})
|
||||
|
||||
it('rejects oversized paneKey', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: 'x'.repeat(300),
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event).toBeNull()
|
||||
})
|
||||
|
||||
it('isolates caches between listener instances', () => {
|
||||
const a = createHookListenerState()
|
||||
const b = createHookListenerState()
|
||||
normalizeHookPayload(
|
||||
a,
|
||||
'claude',
|
||||
{ paneKey: 'p', payload: { hook_event_name: 'UserPromptSubmit', prompt: 'first' } },
|
||||
'production'
|
||||
)
|
||||
// The second listener has no cached prompt for this paneKey, so a tool
|
||||
// event without a fresh prompt should produce empty prompt string.
|
||||
const event = normalizeHookPayload(
|
||||
b,
|
||||
'claude',
|
||||
{
|
||||
paneKey: 'p',
|
||||
payload: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/etc/hosts' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event).not.toBeNull()
|
||||
expect(event!.payload.prompt).toBe('')
|
||||
})
|
||||
|
||||
describe('writeEndpointFile', () => {
|
||||
let dir: string
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'agent-hook-listener-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('writes the endpoint file atomically with the right contents and mode', () => {
|
||||
const finalPath = join(dir, getEndpointFileName())
|
||||
const ok = writeEndpointFile(dir, finalPath, {
|
||||
port: 12345,
|
||||
token: 'abcdef-0123',
|
||||
env: 'production',
|
||||
version: '1'
|
||||
})
|
||||
expect(ok).toBe(true)
|
||||
const text = readFileSync(finalPath, 'utf8')
|
||||
expect(text).toContain('ORCA_AGENT_HOOK_PORT=12345')
|
||||
expect(text).toContain('ORCA_AGENT_HOOK_TOKEN=abcdef-0123')
|
||||
expect(text).toContain('ORCA_AGENT_HOOK_VERSION=1')
|
||||
// POSIX 0o600 — owner read/write only.
|
||||
if (process.platform !== 'win32') {
|
||||
const mode = statSync(finalPath).mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses unsafe values', () => {
|
||||
const finalPath = join(dir, getEndpointFileName())
|
||||
const ok = writeEndpointFile(dir, finalPath, {
|
||||
port: 12345,
|
||||
token: 'safe-token',
|
||||
env: 'foo&bar',
|
||||
version: '1'
|
||||
})
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
|
||||
AGENT_HOOK_NOTIFICATION_METHOD,
|
||||
AGENT_HOOK_REQUEST_REPLAY_METHOD,
|
||||
ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV,
|
||||
isRemoteAgentHooksEnabled,
|
||||
type AgentHookRelayEnvelope
|
||||
} from './agent-hook-relay'
|
||||
|
||||
describe('agent-hook-relay wire shape', () => {
|
||||
it('encodes/decodes through JSON without losing fields', () => {
|
||||
const envelope: AgentHookRelayEnvelope = {
|
||||
source: 'claude',
|
||||
paneKey: 'tab-1:0',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: null,
|
||||
env: 'production',
|
||||
version: '1',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'roundtrip',
|
||||
agentType: 'claude'
|
||||
}
|
||||
}
|
||||
|
||||
const decoded = JSON.parse(JSON.stringify(envelope)) as AgentHookRelayEnvelope
|
||||
expect(decoded).toEqual(envelope)
|
||||
expect(decoded.connectionId).toBeNull()
|
||||
expect(decoded.payload.prompt).toBe('roundtrip')
|
||||
})
|
||||
|
||||
it('exposes stable JSON-RPC method names', () => {
|
||||
expect(AGENT_HOOK_NOTIFICATION_METHOD).toBe('agent.hook')
|
||||
expect(AGENT_HOOK_REQUEST_REPLAY_METHOD).toBe('agent_hook.requestReplay')
|
||||
expect(AGENT_HOOK_INSTALL_PLUGINS_METHOD).toBe('agent_hook.installPlugins')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRemoteAgentHooksEnabled', () => {
|
||||
it('is off when the env var is absent', () => {
|
||||
expect(isRemoteAgentHooksEnabled({})).toBe(false)
|
||||
})
|
||||
|
||||
it('is off for empty / "0"', () => {
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '' })).toBe(false)
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '0' })).toBe(false)
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: ' ' })).toBe(false)
|
||||
})
|
||||
|
||||
it('is on for any other non-empty value', () => {
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '1' })).toBe(true)
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: 'on' })).toBe(true)
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: 'true' })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
// Why: defines the wire shape carried by the JSON-RPC `agent.hook` notification
|
||||
// the relay sends to Orca. Consumed by `src/relay/agent-hook-server.ts` (which
|
||||
// produces it after the shared listener parses an HTTP POST) and by
|
||||
// `src/main/agent-hooks/server.ts` (which ingests it via `ingestRemote`).
|
||||
//
|
||||
// Lives in `shared/` because the relay deliberately has no Electron dependency
|
||||
// (cf. `src/relay/protocol.ts` header). `agent-hook-types.ts` is reserved for
|
||||
// the renderer-bound IPC + installer contract; this module is the wire envelope
|
||||
// between Orca's main process and the remote relay.
|
||||
//
|
||||
// Per the design doc:
|
||||
// - The relay normalizes; Orca routes. The envelope's `payload` field has
|
||||
// already been through `normalizeHookPayload` on the relay side; Orca's
|
||||
// ingestRemote re-runs the canonical normalizer at the trust boundary
|
||||
// (defense-in-depth) before feeding the event into the same `onAgentStatus`
|
||||
// fanout the local HTTP path uses.
|
||||
// - The wire `connectionId` is **always `null`**: a `connectionId` is Orca's
|
||||
// local handle on an `ssh2` connection, not a wire identity. Orca stamps the
|
||||
// real value on receive from `mux` identity inside `ingestRemote`.
|
||||
// - The wire `version` and `env` fields are forwarded verbatim from the agent
|
||||
// CLI's POST body so Orca's existing warn-once cross-build / dev-vs-prod
|
||||
// diagnostics still fire on remote-sourced events.
|
||||
|
||||
import type { ParsedAgentStatusPayload } from './agent-status-types'
|
||||
|
||||
// Why: the local hook server knows the discriminator from URL pathname routing
|
||||
// (`/hook/<source>`); the relay equally must tag each forwarded notification
|
||||
// with the same value so Orca can attribute the event back to the right CLI.
|
||||
// Promoted from `src/main/agent-hooks/server.ts` so the relay can import it
|
||||
// without dragging Electron in (the shared listener module is the only place
|
||||
// that consumes it from the relay side).
|
||||
export type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi'
|
||||
|
||||
/** Wire envelope for a single hook event flowing relay → Orca. */
|
||||
export type AgentHookRelayEnvelope = {
|
||||
source: AgentHookSource
|
||||
paneKey: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
/** Always `null` on the wire — relay does not know Orca's local connectionId. */
|
||||
connectionId: null
|
||||
/** Forwarded verbatim from the agent CLI POST body (e.g. 'production',
|
||||
* 'development'). Lets Orca's warn-once env-mismatch diagnostic fire on
|
||||
* remote events the same as on local. */
|
||||
env?: string
|
||||
/** Forwarded verbatim from the agent CLI POST body. Lets Orca's warn-once
|
||||
* protocol-version diagnostic fire on remote events the same as on local. */
|
||||
version?: string
|
||||
/** Pre-normalized status payload from the relay's `normalizeHookPayload`.
|
||||
* Orca's `ingestRemote` re-validates via `normalizeAgentStatusPayload` at
|
||||
* the trust boundary as defense-in-depth. */
|
||||
payload: ParsedAgentStatusPayload
|
||||
}
|
||||
|
||||
/** JSON-RPC notification method name carried over the relay control channel. */
|
||||
export const AGENT_HOOK_NOTIFICATION_METHOD = 'agent.hook' as const
|
||||
|
||||
/** JSON-RPC request method Orca issues after `--connect` reattach to ask the
|
||||
* relay to replay its per-paneKey last-payload cache. See §5 Path 3 of the
|
||||
* design doc for the race that ruled out push-on-`setWrite`. */
|
||||
export const AGENT_HOOK_REQUEST_REPLAY_METHOD = 'agent_hook.requestReplay' as const
|
||||
|
||||
/** JSON-RPC request method Orca issues at session-ready to ship the
|
||||
* OpenCode/Pi plugin source files to the relay so it can materialize the
|
||||
* per-PTY overlay dirs on the remote. */
|
||||
export const AGENT_HOOK_INSTALL_PLUGINS_METHOD = 'agent_hook.installPlugins' as const
|
||||
|
||||
/** Feature-flag env var. Read once at process start by Orca and the relay.
|
||||
* Absent / empty / "0" = off; anything else = on. See §8 of the design doc
|
||||
* for the gate locations. */
|
||||
export const ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV = 'ORCA_FEATURE_REMOTE_AGENT_HOOKS' as const
|
||||
|
||||
export function isRemoteAgentHooksEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const raw = env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]
|
||||
if (raw === undefined) {
|
||||
return false
|
||||
}
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed.length === 0 || trimmed === '0') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user