mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
feat(agent-hooks): on-disk endpoint discovery for surviving PTYs (v2) (#1196)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -34,6 +34,63 @@ describe('OpenCode hook plugin source', () => {
|
||||
expect(source).toContain('export const OrcaOpenCodeStatusPlugin = async (_ctx) => {')
|
||||
expect(source).toContain('const client = _ctx?.client;')
|
||||
})
|
||||
|
||||
it('resolves hook coords from the endpoint file before falling back to process.env', () => {
|
||||
// Why: a long-running OpenCode session was fork()ed with the prior Orca's
|
||||
// PORT/TOKEN frozen into process.env. The plugin must prefer the on-disk
|
||||
// endpoint file (rewritten on every Orca start()) over env, otherwise it
|
||||
// keeps posting to a dead port after an Orca restart.
|
||||
const source = _internals.getOpenCodePluginSource()
|
||||
|
||||
expect(source).toContain('function readEndpointFile()')
|
||||
expect(source).toContain('process.env.ORCA_AGENT_HOOK_ENDPOINT')
|
||||
// Parser accepts both `KEY=VALUE` (Unix) and `set KEY=VALUE` (Windows):
|
||||
expect(source).toContain('/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/')
|
||||
expect(source).toContain('function resolveHookCoords()')
|
||||
// File takes precedence over env — the whole point of v2:
|
||||
expect(source).toContain(
|
||||
'port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT'
|
||||
)
|
||||
expect(source).toContain(
|
||||
'token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN'
|
||||
)
|
||||
// post() uses the resolved coords, not a cached-at-startup url:
|
||||
expect(source).toContain('const coords = resolveHookCoords();')
|
||||
expect(source).toContain('`http://127.0.0.1:${coords.port}/hook/opencode`')
|
||||
expect(source).toContain('"X-Orca-Agent-Hook-Token": coords.token')
|
||||
})
|
||||
|
||||
it('caches the parsed endpoint file on mtime+size+inode to skip re-reads per post', () => {
|
||||
// Why: message.part.updated fires many times per second during a streaming
|
||||
// assistant reply. Each post() calls resolveHookCoords() which reads the
|
||||
// endpoint file — without the cache we'd readFileSync + parse on every
|
||||
// streamed Part. The cache key combines mtime + size + inode so renameSync
|
||||
// (writeEndpointFile's atomic swap) invalidates the cache via the ino
|
||||
// change even when mtime resolution is coarse and size happens to match.
|
||||
const source = _internals.getOpenCodePluginSource()
|
||||
|
||||
expect(source).toContain('let cachedEndpointKey = "";')
|
||||
expect(source).toContain('let cachedEndpointValues = null;')
|
||||
expect(source).toContain('const stat = fs.statSync(path);')
|
||||
expect(source).toContain('const cacheKey = stat.mtimeMs + ":" + stat.size + ":" + stat.ino;')
|
||||
expect(source).toContain('if (cacheKey === cachedEndpointKey && cachedEndpointValues) {')
|
||||
expect(source).toContain('return cachedEndpointValues;')
|
||||
// Stat failure must invalidate the cache, not lock in stale values:
|
||||
expect(source).toContain('cachedEndpointKey = "";')
|
||||
expect(source).toContain('cachedEndpointValues = null;')
|
||||
})
|
||||
|
||||
it('guards endpoint-file parse warnings with a process-lifetime latch', () => {
|
||||
// Why: ENOENT is the normal pre-install case and must stay silent, but a
|
||||
// malformed/unreadable file (EACCES, EIO, parse error) would otherwise
|
||||
// spam stderr once per hook post. The latch keeps the warning to once per
|
||||
// OpenCode process — mirrors server.ts's warnedVersions/warnedEnvs intent.
|
||||
const source = _internals.getOpenCodePluginSource()
|
||||
|
||||
expect(source).toContain('let warnedBadEndpoint = false;')
|
||||
expect(source).toContain('err.code !== "ENOENT"')
|
||||
expect(source).toContain('warnedBadEndpoint = true;')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenCode id safety guard', () => {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: this file contains a multi-line inline
|
||||
JS plugin source emitted into OpenCode's plugins directory as a single
|
||||
file; splitting the plugin source across TS modules would obscure the
|
||||
runtime artifact and scatter tightly coupled string-template logic. */
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'fs'
|
||||
@@ -44,9 +48,90 @@ function getOpenCodePluginSource(): string {
|
||||
// mapping is done plugin-side (SessionBusy / SessionIdle / PermissionRequest)
|
||||
// so the server-side normalizer can keep its one-event-per-case switch shape.
|
||||
return [
|
||||
'function getHookUrl() {',
|
||||
' const port = process.env.ORCA_AGENT_HOOK_PORT;',
|
||||
' return port ? `http://127.0.0.1:${port}/hook/opencode` : null;',
|
||||
'// Why: process-lifetime guard so a recurring parse error on a malformed',
|
||||
"// endpoint file does not spam OpenCode's stderr once per hook post.",
|
||||
'// This guard lives inside the plugin source because the plugin runs in',
|
||||
"// OpenCode's Node process (not Orca's) and has no access to server.ts's",
|
||||
'// equivalent warnedVersions / warnedEnvs Sets.',
|
||||
'let warnedBadEndpoint = false;',
|
||||
'',
|
||||
'// Why: message.part.updated can fire many times per second during a',
|
||||
'// streaming assistant reply, and each post() calls resolveHookCoords()',
|
||||
'// which reads the endpoint file. The file only changes on Orca restart',
|
||||
'// (rare), so a stat+mtime check is substantially cheaper than a full',
|
||||
'// readFileSync+parse on every streamed part. On stat error we fall',
|
||||
'// through to parse so the fail-open behavior is preserved.',
|
||||
'let cachedEndpointKey = "";',
|
||||
'let cachedEndpointValues = null;',
|
||||
'',
|
||||
'function readEndpointFile() {',
|
||||
' const path = process.env.ORCA_AGENT_HOOK_ENDPOINT;',
|
||||
' if (!path) return null;',
|
||||
' try {',
|
||||
' const fs = require("fs");',
|
||||
' try {',
|
||||
' const stat = fs.statSync(path);',
|
||||
' // Why: cache key combines mtime + size + inode. renameSync (used by',
|
||||
' // writeEndpointFile on the Orca side) allocates a fresh inode on',
|
||||
' // POSIX and a new Windows file ID on NTFS, so ino changes on every',
|
||||
' // legitimate rewrite even when mtimeMs resolution is coarse and size',
|
||||
' // happens to match.',
|
||||
' const cacheKey = stat.mtimeMs + ":" + stat.size + ":" + stat.ino;',
|
||||
' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {',
|
||||
' return cachedEndpointValues;',
|
||||
' }',
|
||||
' const contents = fs.readFileSync(path, "utf8");',
|
||||
' const out = {};',
|
||||
' for (const line of contents.split(/\\r?\\n/)) {',
|
||||
' // Why: Windows endpoint.cmd uses `set KEY=VALUE`; Unix endpoint.env',
|
||||
' // uses `KEY=VALUE`. Making `set ` optional lets the same parser',
|
||||
' // handle both without platform detection in the plugin. Allow',
|
||||
' // digits in the key for forward-compat with future ORCA_AGENT_HOOK_*',
|
||||
' // names that may contain numerics, and strip a trailing CR so',
|
||||
' // mixed-EOL files with lone `\\r` do not leak CR into the value.',
|
||||
' const m = line.match(/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/);',
|
||||
' if (m) out[m[1]] = m[2].replace(/\\r$/, "");',
|
||||
' }',
|
||||
' cachedEndpointKey = cacheKey;',
|
||||
' cachedEndpointValues = out;',
|
||||
' return out;',
|
||||
' } catch (ioErr) {',
|
||||
' // Why: any stat or read failure (file yanked mid-read, permission',
|
||||
' // race, unlink between stat and readFileSync) must invalidate the',
|
||||
' // cache so a transient failure does not lock in a stale parse for',
|
||||
' // the remaining process lifetime; rethrow to the outer catch.',
|
||||
' cachedEndpointKey = "";',
|
||||
' cachedEndpointValues = null;',
|
||||
' throw ioErr;',
|
||||
' }',
|
||||
' } catch (err) {',
|
||||
' // Why: warn once per process if the file exists but is unreadable or',
|
||||
' // malformed — a persistent, silently-swallowed parse error would',
|
||||
' // otherwise leave the plugin falling back to stale process.env on',
|
||||
' // every post with no signal. ENOENT / missing env var is the normal',
|
||||
' // pre-install case; stay silent for it.',
|
||||
' if (err && err.code !== "ENOENT" && !warnedBadEndpoint) {',
|
||||
' warnedBadEndpoint = true;',
|
||||
' console.warn("[orca-hook] failed to parse endpoint file:", err.message);',
|
||||
' }',
|
||||
' return null;',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'function resolveHookCoords() {',
|
||||
' // Why: prefer the on-disk endpoint file over process.env because env was',
|
||||
' // frozen when OpenCode was fork()ed — stale after an Orca restart. The',
|
||||
' // file is rewritten on every Orca start(), so sourcing it per post lets',
|
||||
' // a long-running OpenCode session reach the current server. Falls back',
|
||||
' // to process.env when the file is absent (first-run / pre-endpoint-file / Orca',
|
||||
' // never started writing the file).',
|
||||
' const fileEnv = readEndpointFile() || {};',
|
||||
' return {',
|
||||
' port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT,',
|
||||
' token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN,',
|
||||
' env: fileEnv.ORCA_AGENT_HOOK_ENV || process.env.ORCA_AGENT_HOOK_ENV || "",',
|
||||
' version: fileEnv.ORCA_AGENT_HOOK_VERSION || process.env.ORCA_AGENT_HOOK_VERSION || "",',
|
||||
' };',
|
||||
'}',
|
||||
'',
|
||||
'function getStatusType(event) {',
|
||||
@@ -98,16 +183,20 @@ function getOpenCodePluginSource(): string {
|
||||
'}',
|
||||
'',
|
||||
'async function post(hookEventName, extraProperties) {',
|
||||
' const url = getHookUrl();',
|
||||
' const token = process.env.ORCA_AGENT_HOOK_TOKEN;',
|
||||
' // Why: resolve coords per post — the endpoint file may have been',
|
||||
' // rewritten by a newer Orca since the last call. Pane/tab/worktree IDs',
|
||||
' // stay on process.env because they are per-PTY (stable for the life of',
|
||||
' // the OpenCode process), not per-Orca-instance.',
|
||||
' const coords = resolveHookCoords();',
|
||||
' const paneKey = process.env.ORCA_PANE_KEY;',
|
||||
' if (!url || !token || !paneKey) return;',
|
||||
' if (!coords.port || !coords.token || !paneKey) return;',
|
||||
' const url = `http://127.0.0.1:${coords.port}/hook/opencode`;',
|
||||
' const body = JSON.stringify({',
|
||||
' paneKey,',
|
||||
' tabId: process.env.ORCA_TAB_ID || "",',
|
||||
' worktreeId: process.env.ORCA_WORKTREE_ID || "",',
|
||||
' env: process.env.ORCA_AGENT_HOOK_ENV || "",',
|
||||
' version: process.env.ORCA_AGENT_HOOK_VERSION || "",',
|
||||
' env: coords.env,',
|
||||
' version: coords.version,',
|
||||
' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },',
|
||||
' });',
|
||||
' try {',
|
||||
@@ -115,7 +204,7 @@ function getOpenCodePluginSource(): string {
|
||||
' method: "POST",',
|
||||
' headers: {',
|
||||
' "Content-Type": "application/json",',
|
||||
' "X-Orca-Agent-Hook-Token": token,',
|
||||
' "X-Orca-Agent-Hook-Token": coords.token,',
|
||||
' },',
|
||||
' body,',
|
||||
' });',
|
||||
|
||||
Reference in New Issue
Block a user