fix(opencode): install discoverable atomic plugin

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 17:46:37 -04:00
1 parent 3f43f4aad5
commit 69a75153f5
15 files changed
+1554 -348

No files matched your search

+2 -2
View File
@@ -12,8 +12,8 @@ import {
type KiroIdeDocument,
type KiroIdeRuntimeContract,
} from './kiroide-contract.js';
import { runtimeFilesExist } from './kiroide-io.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
const MAX_HOOK_BYTES = 512 * 1024;
@@ -64,5 +64,5 @@ export async function readKiroCliSources(): Promise<KiroCliSources> {
export async function kiroCliRuntimeFilesExist(
contract: KiroIdeRuntimeContract,
): Promise<boolean> {
return runtimeFilesExist(contract, AGENT_KEY);
return managedRuntimeFilesExist(contract, AGENT_KEY);
}
-96
View File
@@ -1,12 +1,5 @@
import os from 'node:os';
import path from 'node:path';
import { generateGuardScript } from './guard-template.js';
import { generateHookScript } from './hook-template.js';
import { sameKiroIdeAgentId, sameKiroIdePath } from './kiroide-command.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
createKiroIdeDocument,
legacyKiroIdeRuntimeContract,
parseKiroIdeDocument,
@@ -16,10 +9,6 @@ import {
type KiroIdeRuntimeContract,
} from './kiroide-contract.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
const MAX_SECRET_BYTES = 64 * 1024;
const MAX_CONFIG_BYTES = 512 * 1024;
export interface LegacyKiroIdeDocument {
readonly exists: boolean;
@@ -83,88 +72,3 @@ export async function requirePhysicalLegacyDirectory(
throw new Error(`Legacy Kiro IDE hooks directory is missing: ${hooksDirectory}`);
}
}
function requireString(value: unknown, field: string, configPath: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`);
}
return value;
}
function validateRuntimeConfig(
config: JsonObject,
contract: KiroIdeRuntimeContract,
configPath: string,
agentKey: string,
): void {
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
const extra = Object.keys(config).find((key) => !supported.has(key));
if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`);
requireString(config.org_id, 'org_id', configPath);
requireString(config.kid, 'kid', configPath);
const agentId = requireString(config.agent_id, 'agent_id', configPath);
if (!sameKiroIdeAgentId(agentId, contract.agentId) || config.agent_name !== agentKey) {
throw new Error(`Elydora runtime identity does not match ${agentKey} hooks: ${configPath}`);
}
if (config.token !== undefined) requireString(config.token, 'token', configPath);
const rawBaseUrl = requireString(config.base_url, 'base_url', configPath);
let baseUrl: URL;
try {
baseUrl = new URL(rawBaseUrl);
} catch (error) {
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
if (!['http:', 'https:'].includes(baseUrl.protocol)
|| !baseUrl.hostname
|| baseUrl.username
|| baseUrl.password
|| baseUrl.search
|| baseUrl.hash) {
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`);
}
}
function validatePrivateKey(contents: string, keyPath: string): void {
const bytes = Buffer.from(contents, 'base64url');
if (bytes.length !== 32 || bytes.toString('base64url') !== contents) {
throw new Error(`Elydora private key is invalid: ${keyPath}`);
}
}
function validContractPaths(contract: KiroIdeRuntimeContract): boolean {
const agentDirectory = path.dirname(contract.guardPath);
return sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))
&& sameKiroIdePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
&& sameKiroIdePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
}
export async function runtimeFilesExist(
contract: KiroIdeRuntimeContract,
agentKey = AGENT_KEY,
): Promise<boolean> {
if (!validContractPaths(contract)) return false;
const runtimeRoot = path.join(os.homedir(), '.elydora');
const agentDirectory = path.dirname(contract.guardPath);
if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false;
if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) return false;
const configPath = path.join(agentDirectory, 'config.json');
const keyPath = path.join(agentDirectory, 'private.key');
const [config, key, guard, audit] = await Promise.all([
readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES),
readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES),
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
]);
if (!config || !key || !guard || !audit) return false;
validateRuntimeConfig(
parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`),
contract,
configPath,
agentKey,
);
validatePrivateKey(key.contents, keyPath);
return guard.contents === generateGuardScript(agentKey, contract.agentId)
&& audit.contents === generateHookScript(agentKey, contract.agentId, { nativePayload: true });
}
+5 -2
View File
@@ -14,7 +14,8 @@ import {
prepareKiroIdeInstallation,
prepareKiroIdeUninstall,
} from './kiroide-installation.js';
import { readKiroIdeSources, runtimeFilesExist } from './kiroide-io.js';
import { readKiroIdeSources } from './kiroide-io.js';
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
import { SUPPORTED_AGENTS } from './registry.js';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
@@ -60,7 +61,9 @@ export const kiroidePlugin: AgentPlugin = {
const contracts = kiroIdeRuntimeContracts(sources.document.hooks);
const hookConfigured = contracts.length > 0;
const hookScriptExists = hookConfigured
&& (await Promise.all(contracts.map((contract) => runtimeFilesExist(contract)))).every(Boolean);
&& (await Promise.all(contracts.map((contract) => (
managedRuntimeFilesExist(contract, AGENT_KEY)
)))).every(Boolean);
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
+119
View File
@@ -0,0 +1,119 @@
import os from 'node:os';
import path from 'node:path';
import { generateGuardScript, type GuardScriptOptions } from './guard-template.js';
import { generateHookScript, type HookScriptOptions } from './hook-template.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
import { samePath } from './managed-installation.js';
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
const MAX_SECRET_BYTES = 64 * 1024;
const MAX_CONFIG_BYTES = 512 * 1024;
const GUARD_SCRIPT = 'guard.js';
const AUDIT_SCRIPT = 'hook.js';
export interface ManagedRuntimeContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface ManagedRuntimeStatusOptions {
readonly guardOptions?: GuardScriptOptions;
readonly auditOptions?: HookScriptOptions;
}
function sameAgentId(left: string, right: string): boolean {
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
}
function requireString(value: unknown, field: string, configPath: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`);
}
return value;
}
function validateRuntimeConfig(
config: JsonObject,
contract: ManagedRuntimeContract,
configPath: string,
agentKey: string,
): void {
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
const extra = Object.keys(config).find((key) => !supported.has(key));
if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`);
requireString(config.org_id, 'org_id', configPath);
requireString(config.kid, 'kid', configPath);
const agentId = requireString(config.agent_id, 'agent_id', configPath);
if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== agentKey) {
throw new Error(`Elydora runtime identity does not match ${agentKey} hooks: ${configPath}`);
}
if (config.token !== undefined) requireString(config.token, 'token', configPath);
const rawBaseUrl = requireString(config.base_url, 'base_url', configPath);
let baseUrl: URL;
try {
baseUrl = new URL(rawBaseUrl);
} catch (error) {
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
if (!['http:', 'https:'].includes(baseUrl.protocol)
|| !baseUrl.hostname
|| baseUrl.username
|| baseUrl.password
|| baseUrl.search
|| baseUrl.hash) {
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`);
}
}
function validatePrivateKey(contents: string, keyPath: string): void {
const bytes = Buffer.from(contents, 'base64url');
if (bytes.length !== 32 || bytes.toString('base64url') !== contents) {
throw new Error(`Elydora private key is invalid: ${keyPath}`);
}
}
function validContractPaths(contract: ManagedRuntimeContract): boolean {
const runtimeRoot = path.join(os.homedir(), '.elydora');
const agentDirectory = path.dirname(contract.guardPath);
return samePath(path.dirname(agentDirectory), runtimeRoot)
&& sameAgentId(path.basename(agentDirectory), contract.agentId)
&& samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
&& samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
}
export async function managedRuntimeFilesExist(
contract: ManagedRuntimeContract,
agentKey: string,
options: ManagedRuntimeStatusOptions = {},
): Promise<boolean> {
if (!validContractPaths(contract)) return false;
const runtimeRoot = path.join(os.homedir(), '.elydora');
const agentDirectory = path.dirname(contract.guardPath);
if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false;
if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) return false;
const configPath = path.join(agentDirectory, 'config.json');
const keyPath = path.join(agentDirectory, 'private.key');
const [config, key, guard, audit] = await Promise.all([
readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES),
readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES),
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
]);
if (!config || !key || !guard || !audit) return false;
validateRuntimeConfig(
parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`),
contract,
configPath,
agentKey,
);
validatePrivateKey(key.contents, keyPath);
return guard.contents === generateGuardScript(agentKey, contract.agentId, options.guardOptions)
&& audit.contents === generateHookScript(
agentKey,
contract.agentId,
options.auditOptions ?? { nativePayload: true },
);
}
+405
View File
@@ -0,0 +1,405 @@
import os from 'node:os';
import path from 'node:path';
import type { HookScriptOptions } from './hook-template.js';
import { samePath } from './managed-installation.js';
import { parseStrictJsonObject } from './strict-json.js';
export const AGENT_KEY = 'opencode';
export const AUDIT_SCRIPT = 'hook.js';
export const GUARD_SCRIPT = 'guard.js';
export const PLUGIN_FILE = 'elydora-audit.js';
export const LEGACY_PLUGIN_FILE = 'elydora-audit.mjs';
export const OPENCODE_AUDIT_OPTIONS = Object.freeze({
failClosed: true,
nativePayload: true,
}) satisfies HookScriptOptions;
const METADATA_MARKER = '// @elydora-opencode-plugin ';
const METADATA_VERSION = 1;
const MAX_RUNTIME_STDERR_BYTES = 64 * 1024;
const GUARD_TIMEOUT_MILLISECONDS = 5_000;
const AUDIT_TIMEOUT_MILLISECONDS = 7_000;
export interface OpenCodePaths {
readonly homeDirectory: string;
readonly configRoot: string;
readonly configDirectory: string;
readonly pluginsDirectory: string;
readonly pluginPath: string;
readonly legacyPluginPath: string;
}
export interface OpenCodeRuntimeContract {
readonly agentId: string;
readonly executablePath: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface OpenCodeLegacyContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
interface OpenCodePluginMetadata {
readonly version: 1;
readonly agentName: 'opencode';
readonly agentId: string;
readonly executablePath: string;
readonly guardPath: string;
readonly auditPath: string;
}
export function sameOpenCodeAgentId(left: string, right: string): boolean {
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
}
export function resolveOpenCodePaths(
homeDirectory = os.homedir(),
xdgConfigHome = process.env.XDG_CONFIG_HOME,
): OpenCodePaths {
const resolvedHome = path.resolve(homeDirectory);
const configRoot = xdgConfigHome || path.join(resolvedHome, '.config');
if (!path.isAbsolute(configRoot)) {
throw new Error('XDG_CONFIG_HOME must be an absolute path for OpenCode integration');
}
const configDirectory = path.join(configRoot, 'opencode');
const pluginsDirectory = path.join(configDirectory, 'plugins');
return {
homeDirectory: resolvedHome,
configRoot: path.normalize(configRoot),
configDirectory,
pluginsDirectory,
pluginPath: path.join(pluginsDirectory, PLUGIN_FILE),
legacyPluginPath: path.join(pluginsDirectory, LEGACY_PLUGIN_FILE),
};
}
function validAgentSegment(agentId: string): boolean {
return Boolean(agentId)
&& agentId !== '.'
&& agentId !== '..'
&& path.basename(agentId) === agentId;
}
function isNodeExecutable(filePath: string): boolean {
const basename = path.basename(filePath).toLowerCase();
return basename === 'node' || basename === 'node.exe';
}
function validateRuntimePaths(metadata: OpenCodePluginMetadata): void {
if (!validAgentSegment(metadata.agentId)) {
throw new Error('OpenCode plugin metadata contains an invalid agentId');
}
if (!path.isAbsolute(metadata.executablePath) || !isNodeExecutable(metadata.executablePath)) {
throw new Error('OpenCode plugin metadata executablePath must reference Node.js');
}
const agentDirectory = path.join(os.homedir(), '.elydora', metadata.agentId);
if (!samePath(metadata.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
|| !samePath(metadata.auditPath, path.join(agentDirectory, AUDIT_SCRIPT))) {
throw new Error('OpenCode plugin metadata references an unexpected runtime path');
}
}
function validateMetadata(value: unknown): OpenCodePluginMetadata {
const object = value as Record<string, unknown>;
if (!object || typeof object !== 'object' || Array.isArray(object)) {
throw new Error('OpenCode plugin metadata must be an object');
}
if (Object.keys(object).sort().join(',') !== 'agentId,agentName,auditPath,executablePath,guardPath,version') {
throw new Error('OpenCode plugin metadata contains an unexpected field set');
}
if (object.version !== METADATA_VERSION) throw new Error('OpenCode plugin metadata version must be 1');
if (object.agentName !== AGENT_KEY) throw new Error('OpenCode plugin metadata agentName must be opencode');
for (const field of ['agentId', 'executablePath', 'guardPath', 'auditPath']) {
if (typeof object[field] !== 'string' || object[field].length === 0) {
throw new Error(`OpenCode plugin metadata ${field} must be a non-empty string`);
}
}
const metadata = object as unknown as OpenCodePluginMetadata;
validateRuntimePaths(metadata);
return metadata;
}
function encodeMetadata(metadata: OpenCodePluginMetadata): string {
return Buffer.from(JSON.stringify(metadata), 'utf-8').toString('base64url');
}
export function buildOpenCodeMetadata(
agentId: string,
executablePath: string,
guardPath: string,
auditPath: string,
): OpenCodeRuntimeContract {
return validateMetadata({
version: METADATA_VERSION,
agentName: AGENT_KEY,
agentId,
executablePath,
guardPath,
auditPath,
});
}
function normalizeGeneratedSource(source: string): string {
return source.replaceAll('\r\n', '\n');
}
export function buildOpenCodePlugin(contract: OpenCodeRuntimeContract): string {
const metadata = validateMetadata({
version: METADATA_VERSION,
agentName: AGENT_KEY,
...contract,
});
return normalizeGeneratedSource(`// Elydora Audit Plugin for OpenCode
${METADATA_MARKER}${encodeMetadata(metadata)}
// Generated by @elydora/sdk. DO NOT EDIT.
import { spawn, spawnSync } from 'node:child_process';
const RUNTIME_EXECUTABLE = ${JSON.stringify(metadata.executablePath)};
const GUARD_SCRIPT_PATH = ${JSON.stringify(metadata.guardPath)};
const AUDIT_SCRIPT_PATH = ${JSON.stringify(metadata.auditPath)};
const MAX_STDERR_BYTES = ${MAX_RUNTIME_STDERR_BYTES};
const GUARD_TIMEOUT_MS = ${GUARD_TIMEOUT_MILLISECONDS};
const AUDIT_TIMEOUT_MS = ${AUDIT_TIMEOUT_MILLISECONDS};
function eventPayload(eventName, input, output) {
return JSON.stringify({
hook_event_name: eventName,
tool_name: input?.tool || 'unknown',
tool_input: eventName === 'tool.execute.before' ? output?.args : input?.args,
session_id: input?.sessionID || 'unknown',
call_id: input?.callID || 'unknown',
input,
output,
});
}
function runGuard(input, output) {
let payload;
try {
payload = eventPayload('tool.execute.before', input, output);
} catch (error) {
throw new Error('Elydora guard input serialization failed: ' + error.message);
}
const result = spawnSync(RUNTIME_EXECUTABLE, [GUARD_SCRIPT_PATH], {
input: payload,
encoding: 'utf-8',
maxBuffer: MAX_STDERR_BYTES,
timeout: GUARD_TIMEOUT_MS,
windowsHide: true,
});
if (result.error) throw new Error('Elydora guard failed: ' + result.error.message);
const stderr = String(result.stderr || '').trim();
if (result.status !== 0) {
const detail = stderr || (result.signal
? 'Guard terminated by signal ' + result.signal
: 'Guard exited with status ' + result.status);
throw new Error((result.status === 2 ? '' : 'Elydora guard failed: ') + detail);
}
if (stderr) console.error(stderr);
}
function runAudit(input, output) {
let payload;
try {
payload = eventPayload('tool.execute.after', input, output);
} catch (error) {
console.error('[elydora] OpenCode audit input serialization failed:', error.message);
return Promise.resolve();
}
return new Promise((resolve) => {
const child = spawn(RUNTIME_EXECUTABLE, [AUDIT_SCRIPT_PATH], {
stdio: ['pipe', 'ignore', 'pipe'],
windowsHide: true,
});
const stderr = [];
let stderrBytes = 0;
let truncated = false;
let settled = false;
let timer;
const report = (message) => {
console.error('[elydora] OpenCode audit runtime failed:', message);
};
const complete = (failure) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (failure) report(failure);
resolve();
};
const stderrText = () => {
const detail = Buffer.concat(stderr).toString('utf-8').trim();
return truncated ? detail + '\\n[stderr truncated]' : detail;
};
child.stderr.on('data', (chunk) => {
const value = Buffer.from(chunk);
const remaining = MAX_STDERR_BYTES - stderrBytes;
if (remaining <= 0) {
truncated = true;
return;
}
stderr.push(value.subarray(0, remaining));
stderrBytes += Math.min(value.length, remaining);
if (value.length > remaining) truncated = true;
});
child.once('error', (error) => complete(error.message));
child.stdin.once('error', (error) => {
child.kill();
complete('failed to write hook input: ' + error.message);
});
child.once('close', (code, signal) => {
const detail = stderrText();
if (code !== 0 || signal) {
complete(detail || (signal
? 'runtime terminated by signal ' + signal
: 'runtime exited with code ' + code));
return;
}
if (detail) report(detail);
complete();
});
timer = setTimeout(() => {
child.kill();
complete('runtime timed out after ' + AUDIT_TIMEOUT_MS + 'ms');
}, AUDIT_TIMEOUT_MS);
try {
child.stdin.end(payload);
} catch (error) {
child.kill();
complete('failed to write hook input: ' + error.message);
}
});
}
export const ElydoraAuditPlugin = async () => ({
'tool.execute.before': async (input, output) => {
runGuard(input, output);
},
'tool.execute.after': async (input, output) => {
await runAudit(input, output);
},
});
`);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function parseOpenCodePlugin(
filePath: string,
source: string,
): OpenCodeRuntimeContract | undefined {
const markerLine = source.split(/\r?\n/, 3)[1];
if (!markerLine?.startsWith(METADATA_MARKER)) return undefined;
let metadata: OpenCodePluginMetadata;
try {
const encoded = markerLine.slice(METADATA_MARKER.length);
const decoded = Buffer.from(encoded, 'base64url').toString('utf-8');
if (Buffer.from(decoded, 'utf-8').toString('base64url') !== encoded) {
throw new Error('metadata encoding is not canonical base64url');
}
metadata = validateMetadata(parseStrictJsonObject(decoded, 'OpenCode plugin metadata'));
} catch (error) {
throw new Error(`Failed to parse Elydora OpenCode plugin metadata at ${filePath}: ${errorMessage(error)}`, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
if (source !== buildOpenCodePlugin(metadata)) {
throw new Error(`Elydora OpenCode plugin at ${filePath} does not match the managed template`);
}
return metadata;
}
export function buildLegacyOpenCodePlugin(auditPath: string, guardPath: string): string {
return normalizeGeneratedSource(`// Elydora Audit Plugin for OpenCode
// Generated by @elydora/sdk — DO NOT EDIT
import { spawn, spawnSync } from 'node:child_process';
const HOOK_SCRIPT_PATH = ${JSON.stringify(auditPath)};
const GUARD_SCRIPT_PATH = ${JSON.stringify(guardPath)};
export const ElydoraAuditPlugin = async (ctx) => {
return {
"tool.execute.before": async () => {
// Guard — blocks tool if agent is frozen
let result;
try {
result = spawnSync('node', [GUARD_SCRIPT_PATH], {
timeout: 5000,
stdio: ['pipe', 'ignore', 'pipe'],
});
} catch (error) {
throw new Error('Elydora guard failed: ' + error.message);
}
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) => {
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();
},
};
};
`);
}
function legacyRuntimeContract(
guardPath: string,
auditPath: string,
): OpenCodeLegacyContract | undefined {
if (!path.isAbsolute(guardPath) || !path.isAbsolute(auditPath)) return undefined;
const agentDirectory = path.dirname(guardPath);
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))
|| !samePath(auditPath, path.join(agentDirectory, AUDIT_SCRIPT))
|| !samePath(guardPath, path.join(agentDirectory, GUARD_SCRIPT))) return undefined;
const agentId = path.basename(agentDirectory);
return validAgentSegment(agentId) ? { agentId, guardPath, auditPath } : undefined;
}
export function parseLegacyOpenCodePlugin(source: string): OpenCodeLegacyContract | undefined {
const normalized = normalizeGeneratedSource(source);
const auditMatch = normalized.match(/^const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");$/m);
const guardMatch = normalized.match(/^const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");$/m);
if (!auditMatch || !guardMatch) return undefined;
let auditPath: unknown;
let guardPath: unknown;
try {
auditPath = JSON.parse(auditMatch[1]);
guardPath = JSON.parse(guardMatch[1]);
} catch {
return undefined;
}
if (typeof auditPath !== 'string' || typeof guardPath !== 'string'
|| normalized !== buildLegacyOpenCodePlugin(auditPath, guardPath)) return undefined;
return legacyRuntimeContract(guardPath, auditPath);
}
+132
View File
@@ -0,0 +1,132 @@
import path from 'node:path';
import type { InstallConfig } from './base.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
OPENCODE_AUDIT_OPTIONS,
buildOpenCodeMetadata,
buildOpenCodePlugin,
sameOpenCodeAgentId,
} from './opencode-contract.js';
import type { OpenCodePluginFile, OpenCodeSources } from './opencode-io.js';
import {
commitManagedInstallation,
preflightManagedInstallation,
prepareManagedInstallation,
type ManagedHookSource,
type ManagedRuntimePaths,
type PreparedManagedInstallation,
type RenameFile,
} from './managed-installation.js';
import {
commitManagedTransaction,
prepareManagedFileChange,
type ManagedFileChange,
type PreparedManagedTransaction,
} from './managed-transaction.js';
const DISPLAY_NAME = 'OpenCode';
const DIRECTORY_LABEL = 'OpenCode plugins directory';
export type OpenCodeRuntimePaths = ManagedRuntimePaths;
export type PreparedOpenCodeInstallation = PreparedManagedInstallation;
export type { RenameFile };
export async function preflightOpenCodeInstallation(
config: InstallConfig,
sources: OpenCodeSources,
): Promise<OpenCodeRuntimePaths> {
return preflightManagedInstallation({
agentKey: AGENT_KEY,
hookLocations: [{
directoryLabel: DIRECTORY_LABEL,
filePath: sources.paths.pluginPath,
}],
config,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function prepareOpenCodeInstallation(
config: InstallConfig,
sources: OpenCodeSources,
): Promise<PreparedOpenCodeInstallation> {
const paths = await preflightOpenCodeInstallation(config, sources);
const metadata = buildOpenCodeMetadata(
config.agentId,
process.execPath,
paths.guardPath,
paths.auditPath,
);
const hookSources: ManagedHookSource[] = [{
directoryLabel: DIRECTORY_LABEL,
label: 'OpenCode plugin',
filePath: sources.current.filePath,
expectedSource: sources.current.source,
expectedSnapshot: sources.current.snapshot,
source: buildOpenCodePlugin(metadata),
}];
if (sources.legacy.contract) {
hookSources.push({
directoryLabel: DIRECTORY_LABEL,
label: 'legacy OpenCode plugin',
filePath: sources.legacy.filePath,
expectedSource: sources.legacy.source,
expectedSnapshot: sources.legacy.snapshot,
});
}
return prepareManagedInstallation({
agentKey: AGENT_KEY,
displayName: DISPLAY_NAME,
hookSources,
config,
auditOptions: OPENCODE_AUDIT_OPTIONS,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function commitOpenCodeInstallation(
prepared: PreparedOpenCodeInstallation,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedInstallation(prepared, renameFile);
}
function ownedByAgent<TContract extends { readonly agentId: string }>(
file: OpenCodePluginFile<TContract>,
agentId?: string,
): boolean {
return file.contract !== undefined
&& (agentId === undefined || sameOpenCodeAgentId(file.contract.agentId, agentId));
}
export async function prepareOpenCodeUninstall(
sources: OpenCodeSources,
agentId?: string,
): Promise<PreparedManagedTransaction> {
const owned = [sources.current, sources.legacy].filter((file) => ownedByAgent(file, agentId));
const changes = await Promise.all(owned.map((file) => prepareManagedFileChange({
filePath: file.filePath,
label: file.filePath === sources.paths.pluginPath ? 'OpenCode plugin' : 'legacy OpenCode plugin',
next: undefined,
mode: 0o600,
expectedSource: file.source,
expectedSnapshot: file.snapshot,
verifyExpectedSource: true,
})));
return {
displayName: DISPLAY_NAME,
operation: 'uninstall',
directories: owned.map((file) => ({
path: path.dirname(file.filePath),
label: DIRECTORY_LABEL,
})),
changes: changes.filter((change): change is ManagedFileChange => change !== undefined),
};
}
export async function commitOpenCodeUninstall(
prepared: PreparedManagedTransaction,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedTransaction(prepared, renameFile);
}
+98
View File
@@ -0,0 +1,98 @@
import fsp from 'node:fs/promises';
import {
AGENT_KEY,
OPENCODE_AUDIT_OPTIONS,
parseLegacyOpenCodePlugin,
parseOpenCodePlugin,
resolveOpenCodePaths,
type OpenCodeLegacyContract,
type OpenCodePaths,
type OpenCodeRuntimeContract,
} from './opencode-contract.js';
import { inspectPhysicalDirectory, readPhysicalFile, type FileSnapshot } from './managed-files.js';
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
const MAX_PLUGIN_BYTES = 512 * 1024;
export interface OpenCodePluginFile<TContract> {
readonly exists: boolean;
readonly filePath: string;
readonly source?: string;
readonly snapshot?: FileSnapshot;
readonly contract?: TContract;
}
export interface OpenCodeSources {
readonly paths: OpenCodePaths;
readonly current: OpenCodePluginFile<OpenCodeRuntimeContract>;
readonly legacy: OpenCodePluginFile<OpenCodeLegacyContract>;
}
async function inspectOpenCodeDirectories(paths: OpenCodePaths): Promise<void> {
await inspectPhysicalDirectory(paths.homeDirectory, 'OpenCode home directory');
await inspectPhysicalDirectory(paths.configRoot, 'OpenCode XDG config directory');
await inspectPhysicalDirectory(paths.configDirectory, 'OpenCode config directory');
await inspectPhysicalDirectory(paths.pluginsDirectory, 'OpenCode plugins directory');
}
async function readPluginFile<TContract>(
filePath: string,
label: string,
parse: (filePath: string, source: string) => TContract | undefined,
): Promise<OpenCodePluginFile<TContract>> {
const snapshot = await readPhysicalFile(filePath, label, MAX_PLUGIN_BYTES);
if (!snapshot) return { exists: false, filePath };
return {
exists: true,
filePath,
source: snapshot.contents,
snapshot,
contract: parse(filePath, snapshot.contents),
};
}
export async function readOpenCodeSources(): Promise<OpenCodeSources> {
const paths = resolveOpenCodePaths();
await inspectOpenCodeDirectories(paths);
const [current, legacy] = await Promise.all([
readPluginFile(paths.pluginPath, 'OpenCode plugin', parseOpenCodePlugin),
readPluginFile(
paths.legacyPluginPath,
'legacy OpenCode plugin',
(_filePath, source) => parseLegacyOpenCodePlugin(source),
),
]);
return { paths, current, legacy };
}
export function requireAvailableOpenCodePlugin(
file: OpenCodePluginFile<OpenCodeRuntimeContract>,
): void {
if (file.exists && !file.contract) {
throw new Error(`OpenCode plugin path is owned by another integration: ${file.filePath}`);
}
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
async function executableExists(filePath: string): Promise<boolean> {
try {
return (await fsp.stat(filePath)).isFile();
} catch (error) {
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return false;
throw new Error(`Inspect OpenCode Node.js runtime at ${filePath}: ${
error instanceof Error ? error.message : String(error)
}`, { cause: error instanceof Error ? error : new Error(String(error)) });
}
}
export async function openCodeRuntimeFilesExist(
contract: OpenCodeRuntimeContract,
): Promise<boolean> {
return await executableExists(contract.executablePath)
&& await managedRuntimeFilesExist(contract, AGENT_KEY, {
auditOptions: OPENCODE_AUDIT_OPTIONS,
});
}
+40 -111
View File
@@ -1,134 +1,63 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import { AGENT_KEY } from './opencode-contract.js';
import {
commitOpenCodeInstallation,
commitOpenCodeUninstall,
preflightOpenCodeInstallation,
prepareOpenCodeInstallation,
prepareOpenCodeUninstall,
} from './opencode-installation.js';
import {
openCodeRuntimeFilesExist,
readOpenCodeSources,
requireAvailableOpenCodePlugin,
} from './opencode-io.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'opencode';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
return path.join(os.homedir(), entry.configDir);
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function generatePluginScript(hookScriptPath: string, guardScriptPath: string): string {
return `// Elydora Audit Plugin for OpenCode
// Generated by @elydora/sdk — DO NOT EDIT
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 () => {
// Guard — blocks tool if agent is frozen
let result;
try {
result = spawnSync('node', [GUARD_SCRIPT_PATH], {
timeout: 5000,
stdio: ['pipe', 'ignore', 'pipe'],
});
} catch (error) {
throw new Error('Elydora guard failed: ' + error.message);
}
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) => {
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();
},
};
};
`;
function validateSources(sources: Awaited<ReturnType<typeof readOpenCodeSources>>): void {
requireAvailableOpenCodePlugin(sources.current);
}
export const opencodePlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
managesRuntime: true,
const configPath = resolveConfigPath();
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath);
await fsp.writeFile(configPath, script, 'utf-8');
async preflightInstall(config: InstallConfig): Promise<void> {
const sources = await readOpenCodeSources();
validateSources(sources);
await preflightOpenCodeInstallation(config, sources);
},
async uninstall(_agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
try {
await fsp.unlink(configPath);
} catch {
// Already removed
}
async install(config: InstallConfig): Promise<void> {
const sources = await readOpenCodeSources();
validateSources(sources);
const prepared = await prepareOpenCodeInstallation(config, sources);
await commitOpenCodeInstallation(prepared);
console.log(` OpenCode global plugin: ${sources.paths.pluginPath}`);
console.log(' OpenCode activation: restart active OpenCode sessions.');
},
async uninstall(agentId?: string): Promise<void> {
const sources = await readOpenCodeSources();
const prepared = await prepareOpenCodeUninstall(sources, agentId);
await commitOpenCodeUninstall(prepared);
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
let guardScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
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 && guardScriptPath) {
try {
await fsp.access(hookScriptPath);
await fsp.access(guardScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
const sources = await readOpenCodeSources();
validateSources(sources);
const contract = sources.legacy.contract ? undefined : sources.current.contract;
const hookConfigured = contract !== undefined;
const hookScriptExists = contract !== undefined
&& await openCodeRuntimeFilesExist(contract);
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
configPath: sources.paths.pluginPath,
};
},
};
+1 -1
View File
@@ -11,7 +11,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.json' }],
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }],
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.js' }],
['copilot', { name: 'GitHub Copilot CLI', configDir: '~/.copilot/hooks', configFile: 'elydora-audit.json' }],
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],