diff --git a/AGENTS.md b/AGENTS.md index 2c3bb19..492b18b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Resolve Qwen Code 0.20.0 user settings through explicit `QWEN_HOME`, then the initial Qwen home `.env`, then the OS home `.env`; when bootstrap discovers a redirected home, read that home's `.env` as well. Treat empty overrides as unset while preserving their process-level ownership semantics. Read system defaults, user settings, the active trusted workspace, system overrides, and trusted-folder rules with Qwen's documented precedence; write only the user source. Validate the current twenty-one event names and command, HTTP, and prompt handler schemas while preserving future event payloads. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups named `elydora-guard` and `elydora-audit` with ten-second millisecond timeouts. Preserve the complete native snake_case payload, propagate freeze and revocation through exit code `2`, and use PowerShell's call operator with explicit `$LASTEXITCODE` propagation on Windows. Commit user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction, with snapshot preconditions for every consulted routing, policy, trust, and settings source. Require physical files, exact runtime sources, canonical private keys, matching runtime identity, an effective enabled state, `/hooks` review, and restart of active Qwen Code sessions. - Resolve Kiro IDE 1.0 hooks through the current workspace `.kiro/hooks/elydora-audit.json`. Register exact `PreToolUse` and `PostToolUse` command entries in the v1 JSON schema with `.*` matchers, ten-second timeouts, and enabled state. Preserve Kiro's complete native payload and propagate frozen or revoked state through exit code `2`. Use the user-level `~/.kiro/hooks/elydora-audit.kiro.hook` source solely as exact Elydora legacy migration input. Commit the workspace hook file, exact legacy migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require a physical workspace hook path, strict schemas, exact runtime sources, canonical private keys, matching runtime identity, and a complete enabled contract during status checks, then verify both entries in the Agent Hooks panel. - Resolve Kiro CLI v2 through the global `~/.kiro/agents/elydora-audit.json` custom agent and CLI 2.13.0+ v3 through `~/.kiro/hooks/elydora-audit.json`. Register exact lower-camel `preToolUse` and `postToolUse` v2 handlers with `*` matchers and ten-second millisecond timeouts; register exact PascalCase `PreToolUse` and `PostToolUse` v3 handlers in Kiro's v1 schema with `.*` matchers and ten-second timeouts. Preserve the complete native payload, propagate frozen or revoked state through exit code `2`, and use encoded PowerShell commands on Windows. Commit both hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction. Reject path and reserved-name collisions, migrate only exact Elydora command ownership, and require physical sources, strict schemas, canonical private keys, exact runtime sources, matching identity, and a common complete v2/v3 contract during status checks. Validate v2 with `kiro-cli agent validate --path` and activate v3 with `kiro-cli --v3`. +- Resolve OpenCode 1.18.4 global plugins through `${XDG_CONFIG_HOME:-~/.config}/opencode/plugins/elydora-audit.js`; require an absolute `XDG_CONFIG_HOME` and use the official `.js` or `.ts` discovery contract. Export exactly one named `ElydoraAuditPlugin` function. Implement `tool.execute.before` with official `{ tool, sessionID, callID }` input plus `{ args }` output and `tool.execute.after` with official `{ tool, sessionID, callID, args }` input plus `{ title, output, metadata }` output. Preserve both native arguments. Run the guard synchronously with a bounded timeout and propagate frozen or revoked state by throwing; await the audit runtime with a bounded timeout and report failures to stderr after tool completion. Embed the current absolute Node executable in the generated plugin. Commit the plugin, exact legacy `.mjs` migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require physical paths, strict metadata, exact generated sources, canonical private keys, executable identity, matching runtime identity, and a clean migration state during status checks. Restart active OpenCode sessions after installation. ## Code Quality diff --git a/README.md b/README.md index 5f9f375..b327817 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ Kiro IDE 1.0 installation is workspace-scoped. Run the installer from the worksp Kiro CLI installation commits the global v2 custom agent at `~/.kiro/agents/elydora-audit.json`, the CLI 2.13.0+ v3 global hooks at `~/.kiro/hooks/elydora-audit.json`, generated runtimes, runtime config, and private key through one rollback-capable transaction. The v2 agent uses exact lower-camel `preToolUse` and `postToolUse` handlers with ten-second millisecond timeouts. The v3 source uses Kiro's v1 schema with exact PascalCase `PreToolUse` and `PostToolUse` command hooks. Both contracts preserve the complete native event payload and propagate frozen or revoked state through exit code `2`. Validate v2 with `kiro-cli agent validate --path ~/.kiro/agents/elydora-audit.json` and start it with `kiro-cli --agent elydora-audit`; start the v3 TUI with `kiro-cli --v3`. +OpenCode 1.18.4 installation writes the named `ElydoraAuditPlugin` export to `${XDG_CONFIG_HOME:-~/.config}/opencode/plugins/elydora-audit.js`, matching the official global plugin loader's `.js` and `.ts` discovery contract. `XDG_CONFIG_HOME` must be absolute. The `tool.execute.before` hook synchronously evaluates the full native input and argument payload and throws on frozen or revoked state. The awaited `tool.execute.after` hook preserves the complete native input and output payload and reports audit runtime failures to stderr. The plugin, exact legacy `.mjs` migration, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Status requires physical paths, exact generated sources, the installed Node executable, canonical private keys, and matching runtime identity. Restart active OpenCode sessions after installation. + ## API Reference ### Configuration diff --git a/src/plugins/kirocli-io.ts b/src/plugins/kirocli-io.ts index 7c049d3..f4540d6 100644 --- a/src/plugins/kirocli-io.ts +++ b/src/plugins/kirocli-io.ts @@ -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 { export async function kiroCliRuntimeFilesExist( contract: KiroIdeRuntimeContract, ): Promise { - return runtimeFilesExist(contract, AGENT_KEY); + return managedRuntimeFilesExist(contract, AGENT_KEY); } diff --git a/src/plugins/kiroide-io.ts b/src/plugins/kiroide-io.ts index 43ccea3..b6ab3c6 100644 --- a/src/plugins/kiroide-io.ts +++ b/src/plugins/kiroide-io.ts @@ -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 { - 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 }); -} diff --git a/src/plugins/kiroide.ts b/src/plugins/kiroide.ts index ebd9a2e..f80bd04 100644 --- a/src/plugins/kiroide.ts +++ b/src/plugins/kiroide.ts @@ -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, diff --git a/src/plugins/managed-runtime-status.ts b/src/plugins/managed-runtime-status.ts new file mode 100644 index 0000000..bb31c55 --- /dev/null +++ b/src/plugins/managed-runtime-status.ts @@ -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 { + 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 }, + ); +} diff --git a/src/plugins/opencode-contract.ts b/src/plugins/opencode-contract.ts new file mode 100644 index 0000000..8d8458f --- /dev/null +++ b/src/plugins/opencode-contract.ts @@ -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; + 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); +} diff --git a/src/plugins/opencode-installation.ts b/src/plugins/opencode-installation.ts new file mode 100644 index 0000000..2e0c0e9 --- /dev/null +++ b/src/plugins/opencode-installation.ts @@ -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 { + 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 { + 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 { + await commitManagedInstallation(prepared, renameFile); +} + +function ownedByAgent( + file: OpenCodePluginFile, + agentId?: string, +): boolean { + return file.contract !== undefined + && (agentId === undefined || sameOpenCodeAgentId(file.contract.agentId, agentId)); +} + +export async function prepareOpenCodeUninstall( + sources: OpenCodeSources, + agentId?: string, +): Promise { + 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 { + await commitManagedTransaction(prepared, renameFile); +} diff --git a/src/plugins/opencode-io.ts b/src/plugins/opencode-io.ts new file mode 100644 index 0000000..e0f1617 --- /dev/null +++ b/src/plugins/opencode-io.ts @@ -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 { + readonly exists: boolean; + readonly filePath: string; + readonly source?: string; + readonly snapshot?: FileSnapshot; + readonly contract?: TContract; +} + +export interface OpenCodeSources { + readonly paths: OpenCodePaths; + readonly current: OpenCodePluginFile; + readonly legacy: OpenCodePluginFile; +} + +async function inspectOpenCodeDirectories(paths: OpenCodePaths): Promise { + 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( + filePath: string, + label: string, + parse: (filePath: string, source: string) => TContract | undefined, +): Promise> { + 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 { + 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, +): 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 { + 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 { + return await executableExists(contract.executablePath) + && await managedRuntimeFilesExist(contract, AGENT_KEY, { + auditOptions: OPENCODE_AUDIT_OPTIONS, + }); +} diff --git a/src/plugins/opencode.ts b/src/plugins/opencode.ts index 3211737..f75d9ee 100644 --- a/src/plugins/opencode.ts +++ b/src/plugins/opencode.ts @@ -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>): void { + requireAvailableOpenCodePlugin(sources.current); } export const opencodePlugin: AgentPlugin = { - async install(config: InstallConfig): Promise { - 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 { + const sources = await readOpenCodeSources(); + validateSources(sources); + await preflightOpenCodeInstallation(config, sources); }, - async uninstall(_agentId?: string): Promise { - const configPath = resolveConfigPath(); - try { - await fsp.unlink(configPath); - } catch { - // Already removed - } + async install(config: InstallConfig): Promise { + 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 { + const sources = await readOpenCodeSources(); + const prepared = await prepareOpenCodeUninstall(sources, agentId); + await commitOpenCodeUninstall(prepared); }, async status(): Promise { - 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, }; }, }; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 44d4291..a311986 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -11,7 +11,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap = 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' }], diff --git a/test-support/opencode-test-helpers.mjs b/test-support/opencode-test-helpers.mjs new file mode 100644 index 0000000..6ae992c --- /dev/null +++ b/test-support/opencode-test-helpers.mjs @@ -0,0 +1,180 @@ +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const VALID_PRIVATE_KEY = Buffer.alloc(32, 19).toString('base64url'); +export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href; +export const contractModuleUrl = pathToFileURL( + path.resolve('dist/plugins/opencode-contract.js'), +).href; +export const installationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/opencode-installation.js'), +).href; +export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode-io.js')).href; +export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +export const cliPath = path.resolve('dist/cli.js'); + +export function runProcess(command, args, env, cwd, input = '') { + return new Promise((resolve, reject) => { + const childEnvironment = { ...process.env, ...env }; + for (const [name, value] of Object.entries(env)) { + if (value === undefined) delete childEnvironment[name]; + } + const child = spawn(command, args, { + cwd, + env: childEnvironment, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +export function runNode(args, env, cwd, input = '') { + return runProcess(process.execPath, args, env, cwd, input); +} + +export function environment(fixture) { + return { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + XDG_CONFIG_HOME: fixture.configRoot, + }; +} + +export function installConfig(fixture, overrides = {}) { + return { + agentName: 'opencode', + orgId: 'org-1', + agentId: fixture.agentId, + privateKey: VALID_PRIVATE_KEY, + kid: 'kid-1', + token: 'token-1', + baseUrl: fixture.baseUrl, + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + ...overrides, + }; +} + +export async function runPlugin(fixture, method, argument) { + const source = ` + import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await opencodePlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + return runNode( + ['--input-type=module', '--eval', source], + { + ...environment(fixture), + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }, + fixture.projectDir, + ); +} + +async function writeOptional(filePath, value) { + if (value === undefined) return; + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, value, { mode: 0o600 }); +} + +export async function createFixture({ + agentId = 'agent-1', + baseUrl = 'http://127.0.0.1:9', + existingPlugin, + existingLegacy, +} = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-opencode-')); + const homeDir = path.join(rootDir, "home with spaces and 'quote %OPEN%"); + const configRoot = path.join(rootDir, 'xdg config'); + const projectDir = path.join(rootDir, 'workspace with spaces'); + const pluginsDir = path.join(configRoot, 'opencode', 'plugins'); + const pluginPath = path.join(pluginsDir, 'elydora-audit.js'); + const legacyPluginPath = path.join(pluginsDir, 'elydora-audit.mjs'); + const agentDir = path.join(homeDir, '.elydora', agentId); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + await mkdir(homeDir, { recursive: true }); + await mkdir(projectDir, { recursive: true }); + await writeOptional(pluginPath, existingPlugin); + await writeOptional(legacyPluginPath, existingLegacy); + return { + agentDir, + agentId, + baseUrl, + configRoot, + guardScriptPath, + homeDir, + hookScriptPath, + legacyPluginPath, + pluginPath, + pluginsDir, + projectDir, + rootDir, + install(overrides = {}) { + return runPlugin(this, 'install', installConfig(this, overrides)); + }, + async loadPlugin() { + const module = await import(`${pathToFileURL(this.pluginPath).href}?test=${Date.now()}`); + return module.ElydoraAuditPlugin({}); + }, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; +} + +export async function readJson(filePath) { + return JSON.parse(await readFile(filePath, 'utf-8')); +} + +export async function startApiServer(initialStatus = 'active') { + const requests = []; + let status = initialStatus; + let operationStatus = 201; + const server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8'); + requests.push({ method: request.method, url: request.url, raw }); + if (request.method === 'GET') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ agent: { status } })); + return; + } + response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); + response.end(operationStatus < 400 + ? '{"operation":{"accepted":true}}' + : '{"error":{"code":"AUDIT_UNAVAILABLE"}}'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + setStatus(value) { + status = value; + }, + setOperationStatus(value) { + operationStatus = value; + }, + close: () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }), + }; +} diff --git a/test/opencode-official.test.mjs b/test/opencode-official.test.mjs new file mode 100644 index 0000000..5f4e92b --- /dev/null +++ b/test/opencode-official.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + environment, + runProcess, +} from '../test-support/opencode-test-helpers.mjs'; + +const binary = process.env.ELYDORA_OPENCODE_BINARY; + +test( + 'official OpenCode 1.18 discovers the generated global JavaScript plugin', + { skip: binary ? false : 'set ELYDORA_OPENCODE_BINARY to the official OpenCode executable' }, + async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const env = { + ...environment(fixture), + OPENCODE_DISABLE_AUTOUPDATE: 'true', + OPENCODE_DISABLE_MODELS_FETCH: 'true', + }; + const version = await runProcess(binary, ['--version'], env, fixture.projectDir); + assert.equal(version.code, 0, version.stderr); + assert.match(version.stdout, /^1\.18\./); + const paths = await runProcess(binary, ['debug', 'paths'], env, fixture.projectDir); + assert.equal(paths.code, 0, paths.stderr); + assert.equal(paths.stdout.includes(path.join(fixture.configRoot, 'opencode')), true); + const info = await runProcess(binary, ['debug', 'info'], env, fixture.projectDir); + assert.equal(info.code, 0, info.stderr); + assert.match(info.stdout, /elydora-audit\.js/); + assert.doesNotMatch(info.stdout, /elydora-audit\.mjs/); + } finally { + await fixture.close(); + } + }, +); diff --git a/test/opencode-plugin.test.mjs b/test/opencode-plugin.test.mjs index 22db280..2e0b223 100644 --- a/test/opencode-plugin.test.mjs +++ b/test/opencode-plugin.test.mjs @@ -1,169 +1,454 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +import { + lstat, + mkdir, + readFile, + readdir, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; import path from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; -import { pathToFileURL } from 'node:url'; import test from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { + VALID_PRIVATE_KEY, + cliPath, + contractModuleUrl, + createFixture, + environment, + registryModuleUrl, + runNode, + runPlugin, + startApiServer, +} from '../test-support/opencode-test-helpers.mjs'; -function runNode(args, env) { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, args, { - env: { ...process.env, ...env }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - }); +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); } -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'); +async function writeSource(filePath, source) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, source, { mode: 0o600 }); +} - 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)); +async function legacySource(fixture) { + const { buildLegacyOpenCodePlugin } = await import(contractModuleUrl); + return buildLegacyOpenCodePlugin(fixture.hookScriptPath, fixture.guardScriptPath); +} + +async function runGeneratedHook(fixture, name, input, output) { + const source = ` + const module = await import(${JSON.stringify(pathToFileURL(fixture.pluginPath).href)}); + const hooks = await module.ElydoraAuditPlugin({}); + await hooks[${JSON.stringify(name)}]( + JSON.parse(process.env.ELYDORA_TEST_INPUT), + JSON.parse(process.env.ELYDORA_TEST_OUTPUT), + ); `; - const installResult = await runNode( - ['--input-type=module', '--eval', installScript], + return runNode( + ['--input-type=module', '--eval', source], { - HOME: homeDir, - USERPROFILE: homeDir, - ELYDORA_TEST_CONFIG: JSON.stringify({ guardScriptPath, hookScriptPath }), + ...environment(fixture), + ELYDORA_TEST_INPUT: JSON.stringify(input), + ELYDORA_TEST_OUTPUT: JSON.stringify(output), }, + fixture.projectDir, ); - 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", +test('OpenCode installs the discoverable global JavaScript plugin and exact runtime', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('opencode'), { + name: 'OpenCode', + configDir: '.config/opencode/plugins', + configFile: 'elydora-audit.js', }); + 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' } }, - ), - /Agent is frozen by Elydora/, - ); - } finally { - 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']( + const first = await fixture.install(); + assert.equal(first.code, 0, first.stderr); + assert.match(first.stdout, /elydora-audit\.js/); + assert.match(first.stdout, /restart active OpenCode sessions/); + await assertMissing(fixture.legacyPluginPath); + const firstSource = await readFile(fixture.pluginPath, 'utf-8'); + assert.match(firstSource, /@elydora-opencode-plugin/); + assert.match(firstSource, /tool\.execute\.before/); + assert.match(firstSource, /tool\.execute\.after/); + assert.doesNotMatch(firstSource, /spawn\('node'/); + const hooks = await fixture.loadPlugin(); + assert.deepEqual(Object.keys(hooks).sort(), ['tool.execute.after', 'tool.execute.before']); + assert.deepEqual( + JSON.parse(await readFile(path.join(fixture.agentDir, 'config.json'), 'utf-8')), { - tool: 'bash', - sessionID: 'session-1', - callID: 'call-1', - args: { command: 'echo test' }, + org_id: 'org-1', + agent_id: fixture.agentId, + kid: 'kid-1', + base_url: fixture.baseUrl, + token: 'token-1', + agent_name: 'opencode', }, - { 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.equal(await readFile(path.join(fixture.agentDir, 'private.key'), 'utf-8'), VALID_PRIVATE_KEY); + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 0, status.stderr); + assert.deepEqual(JSON.parse(status.stdout), { + installed: true, + agentName: 'opencode', + displayName: 'OpenCode', + hookConfigured: true, + hookScriptExists: true, + configPath: fixture.pluginPath, + }); + const second = await fixture.install(); + assert.equal(second.code, 0, second.stderr); + assert.equal(await readFile(fixture.pluginPath, 'utf-8'), firstSource); + const names = [ + ...await readdir(fixture.pluginsDir), + ...await readdir(fixture.agentDir), + ]; + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false); + if (process.platform !== 'win32') { + assert.equal((await stat(fixture.pluginPath)).mode & 0o777, 0o600); + assert.equal((await stat(fixture.guardScriptPath)).mode & 0o777, 0o700); } - assert(payload, 'audit hook did not receive the tool event'); - assert.deepEqual(JSON.parse(payload), { + } finally { + await fixture.close(); + } +}); + +test('OpenCode guard enforces state and audit preserves the complete native event', async () => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const beforeInput = { tool: 'bash', sessionID: 'session-1', callID: 'call-1' }; + const beforeOutput = { args: { command: 'echo test' } }; + const guard = await runGeneratedHook( + fixture, + 'tool.execute.before', + beforeInput, + beforeOutput, + ); + assert.equal(guard.code, 0, guard.stderr); + assert.equal(api.requests.some(({ method }) => method === 'GET'), true); + + const afterInput = { ...beforeInput, args: beforeOutput.args }; + const afterOutput = { title: 'Shell', output: 'test', metadata: { exit: 0 } }; + const audit = await runGeneratedHook( + fixture, + 'tool.execute.after', + afterInput, + afterOutput, + ); + assert.equal(audit.code, 0, audit.stderr); + const request = api.requests.find(({ method }) => method === 'POST'); + assert.ok(request); + const operation = JSON.parse(request.raw); + assert.deepEqual(operation.action, { tool: 'bash' }); + assert.deepEqual(operation.subject, { session_id: 'session-1' }); + assert.deepEqual(operation.payload, { + hook_event_name: 'tool.execute.after', tool_name: 'bash', tool_input: { command: 'echo test' }, session_id: 'session-1', + call_id: 'call-1', + input: afterInput, + output: afterOutput, }); + + api.setOperationStatus(503); + const failedAudit = await runGeneratedHook( + fixture, + 'tool.execute.after', + afterInput, + afterOutput, + ); + assert.equal(failedAudit.code, 0, failedAudit.stderr); + assert.match(failedAudit.stderr, /OpenCode audit runtime failed: \[Elydora audit\]/); + assert.match( + await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), + /Audit API returned HTTP 503/, + ); + + await rm(path.join(fixture.agentDir, 'status-cache.json')); + api.setStatus('frozen'); + const frozen = await runGeneratedHook( + fixture, + 'tool.execute.before', + beforeInput, + beforeOutput, + ); + assert.equal(frozen.code, 1); + assert.match(frozen.stderr, /Agent "opencode" is frozen.*Tool execution blocked/s); } finally { + await api.close(); 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' }); +test('OpenCode bridge forwards both native arguments and surfaces runtime failures', async () => { + const fixture = await createFixture(); + const guardCapture = path.join(fixture.projectDir, 'guard-input.json'); + const auditCapture = path.join(fixture.projectDir, 'audit-input.json'); try { - assert.equal((await readPluginStatus(fixture.homeDir)).installed, true); + assert.equal((await fixture.install()).code, 0); + await writeFile(fixture.guardScriptPath, ` + const fs = require('node:fs'); + const chunks = []; + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => { + fs.writeFileSync(${JSON.stringify(guardCapture)}, Buffer.concat(chunks)); + process.stderr.write('[guard warning] degraded status API\\n'); + }); + `); + const beforeInput = { tool: 'write', sessionID: 'session-2', callID: 'call-2' }; + const beforeOutput = { args: { filePath: 'a.txt', content: 'value' } }; + const guard = await runGeneratedHook( + fixture, + 'tool.execute.before', + beforeInput, + beforeOutput, + ); + assert.equal(guard.code, 0, guard.stderr); + assert.match(guard.stderr, /guard warning/); + assert.deepEqual(JSON.parse(await readFile(guardCapture, 'utf-8')), { + hook_event_name: 'tool.execute.before', + tool_name: 'write', + tool_input: beforeOutput.args, + session_id: 'session-2', + call_id: 'call-2', + input: beforeInput, + output: beforeOutput, + }); + + await writeFile(fixture.hookScriptPath, ` + const fs = require('node:fs'); + const chunks = []; + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => { + fs.writeFileSync(${JSON.stringify(auditCapture)}, Buffer.concat(chunks)); + process.stderr.write('audit transport failed\\n'); + process.exitCode = 3; + }); + `); + const afterInput = { ...beforeInput, args: beforeOutput.args }; + const afterOutput = { title: 'Write', output: 'saved', metadata: { bytes: 5 } }; + const audit = await runGeneratedHook( + fixture, + 'tool.execute.after', + afterInput, + afterOutput, + ); + assert.equal(audit.code, 0, audit.stderr); + assert.match(audit.stderr, /OpenCode audit runtime failed: audit transport failed/); + assert.deepEqual(JSON.parse(await readFile(auditCapture, 'utf-8')).input, afterInput); + await rm(fixture.guardScriptPath); - assert.equal((await readPluginStatus(fixture.homeDir)).installed, false); + const missing = await runGeneratedHook( + fixture, + 'tool.execute.before', + beforeInput, + beforeOutput, + ); + assert.equal(missing.code, 1); + assert.match(missing.stderr, /Elydora guard failed/); + } finally { + await fixture.close(); + } +}); + +test('OpenCode status requires an exact plugin, runtime identity, and clean migration state', async (t) => { + await t.test('tampered managed plugin', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + await writeFile(fixture.pluginPath, `${await readFile(fixture.pluginPath, 'utf-8')}\n// tampered\n`); + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /does not match the managed template/); + } finally { + await fixture.close(); + } + }); + + for (const [label, target, contents, expected] of [ + ['guard runtime', 'guard', 'tampered\n', /"installed":false/], + ['runtime config', 'config', '{ malformed', /parse Elydora runtime config/i], + ['runtime identity', 'config', JSON.stringify({ + org_id: 'org-1', + agent_id: 'different-agent', + kid: 'kid-1', + base_url: 'http://127.0.0.1:9', + token: 'token-1', + agent_name: 'opencode', + }), /runtime identity/i], + ['private key', 'key', 'invalid', /private key is invalid/i], + ]) { + await t.test(label, async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const targets = { + guard: fixture.guardScriptPath, + config: path.join(fixture.agentDir, 'config.json'), + key: path.join(fixture.agentDir, 'private.key'), + }; + await writeFile(targets[target], contents); + const status = await runPlugin(fixture, 'status', null); + assert.match(`${status.stdout}\n${status.stderr}`, expected); + } finally { + await fixture.close(); + } + }); + } + + await t.test('stale owned mjs source', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + await writeSource(fixture.legacyPluginPath, await legacySource(fixture)); + let status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).hookConfigured, false); + assert.equal((await fixture.install()).code, 0); + await assertMissing(fixture.legacyPluginPath); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, true); + } finally { + await fixture.close(); + } + }); +}); + +test('OpenCode rejects collisions, invalid inputs, and linked plugin paths before writes', async (t) => { + await t.test('user plugin collision', async () => { + const source = 'export const UserPlugin = async () => ({});\n'; + const fixture = await createFixture({ existingPlugin: source }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /owned by another integration/); + assert.equal(await readFile(fixture.pluginPath, 'utf-8'), source); + await assertMissing(fixture.guardScriptPath); + } finally { + await fixture.close(); + } + }); + + await t.test('non-canonical private key', async () => { + const fixture = await createFixture(); + try { + const result = await fixture.install({ privateKey: 'invalid' }); + assert.equal(result.code, 1); + assert.match(result.stderr, /canonical 32-byte/); + await assertMissing(fixture.pluginPath); + } finally { + await fixture.close(); + } + }); + + await t.test('relative XDG_CONFIG_HOME', async () => { + const fixture = await createFixture(); + try { + const source = ` + import { opencodePlugin } from ${JSON.stringify( + pathToFileURL(path.resolve('dist/plugins/opencode.js')).href, + )}; + await opencodePlugin.status(); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir, XDG_CONFIG_HOME: 'relative' }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /XDG_CONFIG_HOME must be an absolute path/); + } finally { + await fixture.close(); + } + }); + + await t.test('linked plugins directory', async () => { + const fixture = await createFixture(); + const external = path.join(fixture.rootDir, 'external-plugins'); + try { + await mkdir(external); + await mkdir(path.dirname(fixture.pluginsDir), { recursive: true }); + await symlink(external, fixture.pluginsDir, process.platform === 'win32' ? 'junction' : 'dir'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /plugins directory is not a physical directory/i); + await assertMissing(fixture.guardScriptPath); + } finally { + await fixture.close(); + } + }); +}); + +test('OpenCode migrates exact legacy ownership and preserves unrelated mjs bytes', async (t) => { + await t.test('install and uninstall exact ownership', async () => { + const fixture = await createFixture(); + try { + await writeSource(fixture.legacyPluginPath, await legacySource(fixture)); + assert.equal((await fixture.install()).code, 0); + await assertMissing(fixture.legacyPluginPath); + const uninstall = await runPlugin(fixture, 'uninstall', fixture.agentId); + assert.equal(uninstall.code, 0, uninstall.stderr); + await assertMissing(fixture.pluginPath); + } finally { + await fixture.close(); + } + }); + + await t.test('unrelated legacy file', async () => { + const source = 'export const UserPlugin = async () => ({});\n'; + const fixture = await createFixture({ existingLegacy: source }); + try { + assert.equal((await fixture.install()).code, 0); + assert.equal(await readFile(fixture.legacyPluginPath, 'utf-8'), source); + assert.equal((await runPlugin(fixture, 'uninstall', fixture.agentId)).code, 0); + assert.equal(await readFile(fixture.legacyPluginPath, 'utf-8'), source); + } finally { + await fixture.close(); + } + }); + + await t.test('legacy-only uninstall', async () => { + const fixture = await createFixture(); + try { + await writeSource(fixture.legacyPluginPath, await legacySource(fixture)); + assert.equal((await runPlugin(fixture, 'uninstall', fixture.agentId)).code, 0); + await assertMissing(fixture.legacyPluginPath); + } finally { + await fixture.close(); + } + }); +}); + +test('OpenCode CLI completes install, status, and uninstall end to end', async () => { + const fixture = await createFixture(); + const privateKeyFile = path.join(fixture.projectDir, 'private-key.txt'); + const tokenFile = path.join(fixture.projectDir, 'token.txt'); + try { + await writeFile(privateKeyFile, `${VALID_PRIVATE_KEY}\n`, { mode: 0o600 }); + await writeFile(tokenFile, 'token-1\n', { mode: 0o600 }); + const install = await runNode([ + cliPath, 'install', '--agent', 'opencode', '--org_id', 'org-1', + '--agent_id', fixture.agentId, '--kid', 'kid-1', + '--private_key_file', privateKeyFile, '--token_file', tokenFile, + '--base_url', fixture.baseUrl, + ], environment(fixture), fixture.projectDir); + assert.equal(install.code, 0, install.stderr); + const status = await runNode([cliPath, 'status'], environment(fixture), fixture.projectDir); + assert.equal(status.code, 0, status.stderr); + assert.match(status.stdout, /OpenCode \(opencode\) \[installed\]/); + const uninstall = await runNode([ + cliPath, 'uninstall', '--agent', 'opencode', '--agent_id', fixture.agentId, + ], environment(fixture), fixture.projectDir); + assert.equal(uninstall.code, 0, uninstall.stderr); + await assertMissing(fixture.pluginPath); + await assertMissing(fixture.agentDir); } finally { await fixture.close(); } diff --git a/test/opencode-transaction.test.mjs b/test/opencode-transaction.test.mjs new file mode 100644 index 0000000..7499013 --- /dev/null +++ b/test/opencode-transaction.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + contractModuleUrl, + createFixture, + environment, + installConfig, + installationModuleUrl, + ioModuleUrl, + runNode, +} from '../test-support/opencode-test-helpers.mjs'; + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +async function runPreparedInstallation(fixture, { failLegacy = false, racePlugin } = {}) { + const source = ` + import fsp from 'node:fs/promises'; + import path from 'node:path'; + import { + commitOpenCodeInstallation, + prepareOpenCodeInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + import { readOpenCodeSources } from ${JSON.stringify(ioModuleUrl)}; + + const config = JSON.parse(process.env.ELYDORA_TEST_CONFIG); + const sources = await readOpenCodeSources(); + const prepared = await prepareOpenCodeInstallation(config, sources); + if (process.env.ELYDORA_TEST_RACE_PLUGIN) { + await fsp.mkdir(path.dirname(sources.paths.pluginPath), { recursive: true }); + await fsp.writeFile(sources.paths.pluginPath, process.env.ELYDORA_TEST_RACE_PLUGIN); + } + let failed = false; + await commitOpenCodeInstallation(prepared, async (from, to) => { + if (!failed + && process.env.ELYDORA_TEST_FAIL_LEGACY === 'true' + && from === sources.paths.legacyPluginPath + && to.endsWith('.rollback')) { + failed = true; + throw new Error('injected OpenCode legacy migration failure'); + } + await fsp.rename(from, to); + }); + `; + return runNode( + ['--input-type=module', '--eval', source], + { + ...environment(fixture), + ELYDORA_TEST_CONFIG: JSON.stringify(installConfig(fixture)), + ELYDORA_TEST_FAIL_LEGACY: String(failLegacy), + ELYDORA_TEST_RACE_PLUGIN: racePlugin, + }, + fixture.projectDir, + ); +} + +async function assertNoTransactionFiles(...directories) { + const names = []; + for (const directory of directories) { + try { + names.push(...await readdir(directory)); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', ')); +} + +test('OpenCode rolls back plugin, legacy migration, and runtime after a late failure', async () => { + const fixture = await createFixture(); + const { buildLegacyOpenCodePlugin } = await import(contractModuleUrl); + const legacy = buildLegacyOpenCodePlugin(fixture.hookScriptPath, fixture.guardScriptPath); + try { + await mkdir(path.dirname(fixture.legacyPluginPath), { recursive: true }); + await writeFile(fixture.legacyPluginPath, legacy); + const result = await runPreparedInstallation(fixture, { failLegacy: true }); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected OpenCode legacy migration failure/); + assert.equal(await readFile(fixture.legacyPluginPath, 'utf-8'), legacy); + await assertMissing(fixture.pluginPath); + for (const filePath of [ + fixture.guardScriptPath, + fixture.hookScriptPath, + path.join(fixture.agentDir, 'config.json'), + path.join(fixture.agentDir, 'private.key'), + ]) await assertMissing(filePath); + await assertNoTransactionFiles(fixture.pluginsDir, fixture.agentDir); + } finally { + await fixture.close(); + } +}); + +test('OpenCode detects concurrent plugin creation before committing runtime files', async () => { + const fixture = await createFixture(); + const racePlugin = 'export const RacePlugin = async () => ({});\n'; + try { + const result = await runPreparedInstallation(fixture, { racePlugin }); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed during OpenCode installation/i); + assert.equal(await readFile(fixture.pluginPath, 'utf-8'), racePlugin); + await assertMissing(fixture.guardScriptPath); + await assertMissing(fixture.hookScriptPath); + await assertNoTransactionFiles(fixture.pluginsDir, fixture.agentDir); + } finally { + await fixture.close(); + } +});