From 212fb61e83a7d451869e7982aa9759d927095b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 02:01:56 -0400 Subject: [PATCH] fix: align OpenCode hook event contract --- src/plugins/opencode.ts | 81 +++++++-------- test/opencode-plugin.test.mjs | 179 ++++++++++++++++++++++++++-------- 2 files changed, 181 insertions(+), 79 deletions(-) diff --git a/src/plugins/opencode.ts b/src/plugins/opencode.ts index 906db75..3211737 100644 --- a/src/plugins/opencode.ts +++ b/src/plugins/opencode.ts @@ -21,49 +21,51 @@ function generatePluginScript(hookScriptPath: string, guardScriptPath: string): import { spawn, spawnSync } from 'node:child_process'; +const HOOK_SCRIPT_PATH = ${JSON.stringify(hookScriptPath)}; +const GUARD_SCRIPT_PATH = ${JSON.stringify(guardScriptPath)}; + export const ElydoraAuditPlugin = async (ctx) => { return { - "tool.execute.before": async (input, output) => { + "tool.execute.before": async () => { // Guard — blocks tool if agent is frozen let result; try { - result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], { + result = spawnSync('node', [GUARD_SCRIPT_PATH], { timeout: 5000, stdio: ['pipe', 'ignore', 'pipe'], }); - } catch { - // Fail-open — allow if guard can't run - return; + } catch (error) { + throw new Error('Elydora guard failed: ' + error.message); } - if (result.status === 2) { - const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.'; - throw new Error(msg); + if (result.error) { + throw new Error('Elydora guard failed: ' + result.error.message); + } + if (result.status !== 0) { + const message = result.stderr?.toString().trim() || 'Guard exited with status ' + result.status; + const prefix = result.status === 2 ? '' : 'Elydora guard failed: '; + throw new Error(prefix + message); } }, "tool.execute.after": async (input, output) => { - try { - const toolName = input?.tool || 'unknown'; - const toolInput = output?.args || input?.args || {}; - const sessionId = ctx?.project?.name || 'unknown'; - - const data = JSON.stringify({ - tool_name: toolName, - tool_input: toolInput, - session_id: sessionId, - }); - - const child = spawn('node', ['__ELYDORA_HOOK_SCRIPT_PATH__'], { - detached: true, - stdio: ['pipe', 'ignore', 'ignore'], - }); - child.stdin.write(data); - child.stdin.end(); - child.unref(); - } catch { - // Fire-and-forget — never block the developer - } + const data = JSON.stringify({ + tool_name: input?.tool || 'unknown', + tool_input: input?.args || output?.args || {}, + session_id: input?.sessionID || ctx?.project?.name || 'unknown', + }); + const child = spawn('node', [HOOK_SCRIPT_PATH], { + detached: true, + stdio: ['pipe', 'ignore', 'ignore'], + }); + child.on('error', (error) => { + console.error('[elydora] audit hook failed:', error.message); + }); + child.stdin.on('error', (error) => { + console.error('[elydora] audit input failed:', error.message); + }); + child.stdin.end(data); + child.unref(); }, }; }; @@ -76,9 +78,7 @@ export const opencodePlugin: AgentPlugin = { await fsp.mkdir(configDir, { recursive: true }); const configPath = resolveConfigPath(); - const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath) - .replace('__ELYDORA_HOOK_SCRIPT_PATH__', config.hookScriptPath.replace(/\\/g, '\\\\')) - .replace('__ELYDORA_GUARD_SCRIPT_PATH__', config.guardScriptPath.replace(/\\/g, '\\\\')); + const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath); await fsp.writeFile(configPath, script, 'utf-8'); }, @@ -97,24 +97,25 @@ export const opencodePlugin: AgentPlugin = { let hookConfigured = false; let hookScriptPath = ''; + let guardScriptPath = ''; try { const raw = await fsp.readFile(configPath, 'utf-8'); - hookConfigured = true; - - // Extract hook script path from plugin script content - // The plugin script contains a spawn call like: spawn('node', ['']) - const match = raw.match(/spawn\('node',\s*\['([^']*elydora[^']*)'\]/); - if (match) { - hookScriptPath = match[1]; + const hookMatch = raw.match(/const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/); + const guardMatch = raw.match(/const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/); + if (hookMatch && guardMatch) { + hookScriptPath = JSON.parse(hookMatch[1]); + guardScriptPath = JSON.parse(guardMatch[1]); + hookConfigured = true; } } catch { // File doesn't exist } let hookScriptExists = false; - if (hookScriptPath) { + if (hookScriptPath && guardScriptPath) { try { await fsp.access(hookScriptPath); + await fsp.access(guardScriptPath); hookScriptExists = true; } catch { // File doesn't exist diff --git a/test/opencode-plugin.test.mjs b/test/opencode-plugin.test.mjs index 46d2465..22db280 100644 --- a/test/opencode-plugin.test.mjs +++ b/test/opencode-plugin.test.mjs @@ -1,8 +1,9 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; import test from 'node:test'; @@ -21,49 +22,149 @@ function runNode(args, env) { }); } -test('OpenCode rejects tool execution when the Elydora guard blocks', async () => { +async function createFixture({ guardSource, hookSource }) { const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-opencode-')); + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + await mkdir(agentDir, { recursive: true }); + const guardScriptPath = path.join(agentDir, 'guard.cjs'); + const hookScriptPath = path.join(agentDir, 'hook.cjs'); + if (guardSource !== undefined) await writeFile(guardScriptPath, guardSource); + await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n'); + + const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href; + const installScript = ` + import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)}; + await opencodePlugin.install(JSON.parse(process.env.ELYDORA_TEST_CONFIG)); + `; + const installResult = await runNode( + ['--input-type=module', '--eval', installScript], + { + HOME: homeDir, + USERPROFILE: homeDir, + ELYDORA_TEST_CONFIG: JSON.stringify({ guardScriptPath, hookScriptPath }), + }, + ); + assert.equal(installResult.code, 0, installResult.stderr); + + const generatedPath = path.join( + homeDir, + '.config', + 'opencode', + 'plugins', + 'elydora-audit.mjs', + ); + const generatedPlugin = await import(`${pathToFileURL(generatedPath).href}?test=${Date.now()}`); + const hooks = await generatedPlugin.ElydoraAuditPlugin({ project: { name: 'project-fallback' } }); + + return { + guardScriptPath, + homeDir, + hooks, + async close() { + await rm(homeDir, { recursive: true, force: true }); + }, + }; +} + +async function readPluginStatus(homeDir) { + const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href; + const statusScript = ` + import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)}; + console.log(JSON.stringify(await opencodePlugin.status())); + `; + const result = await runNode( + ['--input-type=module', '--eval', statusScript], + { HOME: homeDir, USERPROFILE: homeDir }, + ); + assert.equal(result.code, 0, result.stderr); + return JSON.parse(result.stdout); +} + +test('OpenCode rejects tool execution when the Elydora guard blocks', async () => { + const fixture = await createFixture({ + guardSource: "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n", + }); try { - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - await mkdir(agentDir, { recursive: true }); - const guardScriptPath = path.join(agentDir, 'guard.cjs'); - const hookScriptPath = path.join(agentDir, 'hook.cjs'); - await writeFile( - guardScriptPath, - "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n", - ); - await writeFile(hookScriptPath, 'process.exit(0);\n'); - - const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href; - const installScript = ` - import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)}; - await opencodePlugin.install(JSON.parse(process.env.ELYDORA_TEST_CONFIG)); - `; - const installResult = await runNode( - ['--input-type=module', '--eval', installScript], - { - HOME: homeDir, - USERPROFILE: homeDir, - ELYDORA_TEST_CONFIG: JSON.stringify({ guardScriptPath, hookScriptPath }), - }, - ); - assert.equal(installResult.code, 0, installResult.stderr); - - const generatedPath = path.join( - homeDir, - '.config', - 'opencode', - 'plugins', - 'elydora-audit.mjs', - ); - const generatedPlugin = await import(`${pathToFileURL(generatedPath).href}?test=${Date.now()}`); - const hooks = await generatedPlugin.ElydoraAuditPlugin({ project: { name: 'test' } }); - await assert.rejects( - hooks['tool.execute.before']({ tool: 'bash' }, { args: { command: 'echo test' } }), + fixture.hooks['tool.execute.before']( + { tool: 'bash', sessionID: 'session-1', callID: 'call-1' }, + { args: { command: 'echo test' } }, + ), /Agent is frozen by Elydora/, ); } finally { - await rm(homeDir, { recursive: true, force: true }); + await fixture.close(); + } +}); + +test('OpenCode blocks when the guard process cannot start', async () => { + const fixture = await createFixture({}); + try { + await assert.rejects( + fixture.hooks['tool.execute.before']( + { tool: 'bash', sessionID: 'session-1', callID: 'call-1' }, + { args: { command: 'echo test' } }, + ), + /Elydora guard failed/, + ); + } finally { + await fixture.close(); + } +}); + +test('OpenCode forwards the current tool event contract to the audit hook', async () => { + const capturePath = path.join(os.tmpdir(), `elydora-event-${process.pid}-${Date.now()}.json`); + const hookSource = ` + const fs = require('node:fs'); + const chunks = []; + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks))); + `; + const fixture = await createFixture({ + guardSource: 'process.exit(0);\n', + hookSource, + }); + try { + await fixture.hooks['tool.execute.after']( + { + tool: 'bash', + sessionID: 'session-1', + callID: 'call-1', + args: { command: 'echo test' }, + }, + { title: 'Shell', output: 'test' }, + ); + + let payload; + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + try { + payload = await readFile(capturePath, 'utf-8'); + break; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + await delay(20); + } + } + assert(payload, 'audit hook did not receive the tool event'); + assert.deepEqual(JSON.parse(payload), { + tool_name: 'bash', + tool_input: { command: 'echo test' }, + session_id: 'session-1', + }); + } finally { + await fixture.close(); + await rm(capturePath, { force: true }); + } +}); + +test('OpenCode status requires both generated runtime scripts', async () => { + const fixture = await createFixture({ guardSource: 'process.exit(0);\n' }); + try { + assert.equal((await readPluginStatus(fixture.homeDir)).installed, true); + await rm(fixture.guardScriptPath); + assert.equal((await readPluginStatus(fixture.homeDir)).installed, false); + } finally { + await fixture.close(); } });