fix(opencode): install discoverable atomic plugin
This commit is contained in:
1 parent
3f43f4aad5
commit
69a75153f5
15 files changed
+1554
-348
No files matched your search
@@ -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 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 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 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
|
## Code Quality
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
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
|
## API Reference
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
type KiroIdeDocument,
|
type KiroIdeDocument,
|
||||||
type KiroIdeRuntimeContract,
|
type KiroIdeRuntimeContract,
|
||||||
} from './kiroide-contract.js';
|
} from './kiroide-contract.js';
|
||||||
import { runtimeFilesExist } from './kiroide-io.js';
|
|
||||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||||
|
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||||
|
|
||||||
const MAX_HOOK_BYTES = 512 * 1024;
|
const MAX_HOOK_BYTES = 512 * 1024;
|
||||||
|
|
||||||
@@ -64,5 +64,5 @@ export async function readKiroCliSources(): Promise<KiroCliSources> {
|
|||||||
export async function kiroCliRuntimeFilesExist(
|
export async function kiroCliRuntimeFilesExist(
|
||||||
contract: KiroIdeRuntimeContract,
|
contract: KiroIdeRuntimeContract,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return runtimeFilesExist(contract, AGENT_KEY);
|
return managedRuntimeFilesExist(contract, AGENT_KEY);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,5 @@
|
|||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
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 {
|
import {
|
||||||
AGENT_KEY,
|
|
||||||
AUDIT_SCRIPT,
|
|
||||||
GUARD_SCRIPT,
|
|
||||||
createKiroIdeDocument,
|
createKiroIdeDocument,
|
||||||
legacyKiroIdeRuntimeContract,
|
legacyKiroIdeRuntimeContract,
|
||||||
parseKiroIdeDocument,
|
parseKiroIdeDocument,
|
||||||
@@ -16,10 +9,6 @@ import {
|
|||||||
type KiroIdeRuntimeContract,
|
type KiroIdeRuntimeContract,
|
||||||
} from './kiroide-contract.js';
|
} from './kiroide-contract.js';
|
||||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.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 {
|
export interface LegacyKiroIdeDocument {
|
||||||
readonly exists: boolean;
|
readonly exists: boolean;
|
||||||
@@ -83,88 +72,3 @@ export async function requirePhysicalLegacyDirectory(
|
|||||||
throw new Error(`Legacy Kiro IDE hooks directory is missing: ${hooksDirectory}`);
|
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 });
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
prepareKiroIdeInstallation,
|
prepareKiroIdeInstallation,
|
||||||
prepareKiroIdeUninstall,
|
prepareKiroIdeUninstall,
|
||||||
} from './kiroide-installation.js';
|
} 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';
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
@@ -60,7 +61,9 @@ export const kiroidePlugin: AgentPlugin = {
|
|||||||
const contracts = kiroIdeRuntimeContracts(sources.document.hooks);
|
const contracts = kiroIdeRuntimeContracts(sources.document.hooks);
|
||||||
const hookConfigured = contracts.length > 0;
|
const hookConfigured = contracts.length > 0;
|
||||||
const hookScriptExists = hookConfigured
|
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 {
|
return {
|
||||||
installed: hookConfigured && hookScriptExists,
|
installed: hookConfigured && hookScriptExists,
|
||||||
agentName: AGENT_KEY,
|
agentName: AGENT_KEY,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 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';
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
const AGENT_KEY = 'opencode';
|
|
||||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
function resolveConfigDir(): string {
|
function validateSources(sources: Awaited<ReturnType<typeof readOpenCodeSources>>): void {
|
||||||
return path.join(os.homedir(), entry.configDir);
|
requireAvailableOpenCodePlugin(sources.current);
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const opencodePlugin: AgentPlugin = {
|
export const opencodePlugin: AgentPlugin = {
|
||||||
async install(config: InstallConfig): Promise<void> {
|
managesRuntime: true,
|
||||||
const configDir = resolveConfigDir();
|
|
||||||
await fsp.mkdir(configDir, { recursive: true });
|
|
||||||
|
|
||||||
const configPath = resolveConfigPath();
|
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||||
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath);
|
const sources = await readOpenCodeSources();
|
||||||
|
validateSources(sources);
|
||||||
await fsp.writeFile(configPath, script, 'utf-8');
|
await preflightOpenCodeInstallation(config, sources);
|
||||||
},
|
},
|
||||||
|
|
||||||
async uninstall(_agentId?: string): Promise<void> {
|
async install(config: InstallConfig): Promise<void> {
|
||||||
const configPath = resolveConfigPath();
|
const sources = await readOpenCodeSources();
|
||||||
try {
|
validateSources(sources);
|
||||||
await fsp.unlink(configPath);
|
const prepared = await prepareOpenCodeInstallation(config, sources);
|
||||||
} catch {
|
await commitOpenCodeInstallation(prepared);
|
||||||
// Already removed
|
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> {
|
async status(): Promise<PluginStatus> {
|
||||||
const configPath = resolveConfigPath();
|
const sources = await readOpenCodeSources();
|
||||||
|
validateSources(sources);
|
||||||
let hookConfigured = false;
|
const contract = sources.legacy.contract ? undefined : sources.current.contract;
|
||||||
let hookScriptPath = '';
|
const hookConfigured = contract !== undefined;
|
||||||
let guardScriptPath = '';
|
const hookScriptExists = contract !== undefined
|
||||||
try {
|
&& await openCodeRuntimeFilesExist(contract);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
installed: hookConfigured && hookScriptExists,
|
installed: hookConfigured && hookScriptExists,
|
||||||
agentName: AGENT_KEY,
|
agentName: AGENT_KEY,
|
||||||
displayName: entry.name,
|
displayName: entry.name,
|
||||||
hookConfigured,
|
hookConfigured,
|
||||||
hookScriptExists,
|
hookScriptExists,
|
||||||
configPath,
|
configPath: sources.paths.pluginPath,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -11,7 +11,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map
|
|||||||
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
|
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
|
||||||
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
|
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
|
||||||
['kiroide', { name: 'Kiro IDE', 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' }],
|
['copilot', { name: 'GitHub Copilot CLI', configDir: '~/.copilot/hooks', configFile: 'elydora-audit.json' }],
|
||||||
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
|
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
|
||||||
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],
|
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
+421
-136
@@ -1,169 +1,454 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { spawn } from 'node:child_process';
|
import {
|
||||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
lstat,
|
||||||
import os from 'node:os';
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
symlink,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { setTimeout as delay } from 'node:timers/promises';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
import test from 'node:test';
|
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) {
|
async function assertMissing(filePath) {
|
||||||
return new Promise((resolve, reject) => {
|
await assert.rejects(lstat(filePath), { code: 'ENOENT' });
|
||||||
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 createFixture({ guardSource, hookSource }) {
|
async function writeSource(filePath, source) {
|
||||||
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-opencode-'));
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
|
await writeFile(filePath, source, { mode: 0o600 });
|
||||||
await mkdir(agentDir, { recursive: true });
|
}
|
||||||
const guardScriptPath = path.join(agentDir, 'guard.cjs');
|
|
||||||
const hookScriptPath = path.join(agentDir, 'hook.cjs');
|
|
||||||
if (guardSource !== undefined) await writeFile(guardScriptPath, guardSource);
|
|
||||||
await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n');
|
|
||||||
|
|
||||||
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href;
|
async function legacySource(fixture) {
|
||||||
const installScript = `
|
const { buildLegacyOpenCodePlugin } = await import(contractModuleUrl);
|
||||||
import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)};
|
return buildLegacyOpenCodePlugin(fixture.hookScriptPath, fixture.guardScriptPath);
|
||||||
await opencodePlugin.install(JSON.parse(process.env.ELYDORA_TEST_CONFIG));
|
}
|
||||||
|
|
||||||
|
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(
|
return runNode(
|
||||||
['--input-type=module', '--eval', installScript],
|
['--input-type=module', '--eval', source],
|
||||||
{
|
{
|
||||||
HOME: homeDir,
|
...environment(fixture),
|
||||||
USERPROFILE: homeDir,
|
ELYDORA_TEST_INPUT: JSON.stringify(input),
|
||||||
ELYDORA_TEST_CONFIG: JSON.stringify({ guardScriptPath, hookScriptPath }),
|
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) {
|
test('OpenCode installs the discoverable global JavaScript plugin and exact runtime', async () => {
|
||||||
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href;
|
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
||||||
const statusScript = `
|
assert.deepEqual(SUPPORTED_AGENTS.get('opencode'), {
|
||||||
import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)};
|
name: 'OpenCode',
|
||||||
console.log(JSON.stringify(await opencodePlugin.status()));
|
configDir: '.config/opencode/plugins',
|
||||||
`;
|
configFile: 'elydora-audit.js',
|
||||||
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",
|
|
||||||
});
|
});
|
||||||
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
await assert.rejects(
|
const first = await fixture.install();
|
||||||
fixture.hooks['tool.execute.before'](
|
assert.equal(first.code, 0, first.stderr);
|
||||||
{ tool: 'bash', sessionID: 'session-1', callID: 'call-1' },
|
assert.match(first.stdout, /elydora-audit\.js/);
|
||||||
{ args: { command: 'echo test' } },
|
assert.match(first.stdout, /restart active OpenCode sessions/);
|
||||||
),
|
await assertMissing(fixture.legacyPluginPath);
|
||||||
/Agent is frozen by Elydora/,
|
const firstSource = await readFile(fixture.pluginPath, 'utf-8');
|
||||||
);
|
assert.match(firstSource, /@elydora-opencode-plugin/);
|
||||||
} finally {
|
assert.match(firstSource, /tool\.execute\.before/);
|
||||||
await fixture.close();
|
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']);
|
||||||
test('OpenCode blocks when the guard process cannot start', async () => {
|
assert.deepEqual(
|
||||||
const fixture = await createFixture({});
|
JSON.parse(await readFile(path.join(fixture.agentDir, 'config.json'), 'utf-8')),
|
||||||
try {
|
|
||||||
await assert.rejects(
|
|
||||||
fixture.hooks['tool.execute.before'](
|
|
||||||
{ tool: 'bash', sessionID: 'session-1', callID: 'call-1' },
|
|
||||||
{ args: { command: 'echo test' } },
|
|
||||||
),
|
|
||||||
/Elydora guard failed/,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('OpenCode forwards the current tool event contract to the audit hook', async () => {
|
|
||||||
const capturePath = path.join(os.tmpdir(), `elydora-event-${process.pid}-${Date.now()}.json`);
|
|
||||||
const hookSource = `
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const chunks = [];
|
|
||||||
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks)));
|
|
||||||
`;
|
|
||||||
const fixture = await createFixture({
|
|
||||||
guardSource: 'process.exit(0);\n',
|
|
||||||
hookSource,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await fixture.hooks['tool.execute.after'](
|
|
||||||
{
|
{
|
||||||
tool: 'bash',
|
org_id: 'org-1',
|
||||||
sessionID: 'session-1',
|
agent_id: fixture.agentId,
|
||||||
callID: 'call-1',
|
kid: 'kid-1',
|
||||||
args: { command: 'echo test' },
|
base_url: fixture.baseUrl,
|
||||||
|
token: 'token-1',
|
||||||
|
agent_name: 'opencode',
|
||||||
},
|
},
|
||||||
{ title: 'Shell', output: 'test' },
|
|
||||||
);
|
);
|
||||||
|
assert.equal(await readFile(path.join(fixture.agentDir, 'private.key'), 'utf-8'), VALID_PRIVATE_KEY);
|
||||||
let payload;
|
const status = await runPlugin(fixture, 'status', null);
|
||||||
const deadline = Date.now() + 3000;
|
assert.equal(status.code, 0, status.stderr);
|
||||||
while (Date.now() < deadline) {
|
assert.deepEqual(JSON.parse(status.stdout), {
|
||||||
try {
|
installed: true,
|
||||||
payload = await readFile(capturePath, 'utf-8');
|
agentName: 'opencode',
|
||||||
break;
|
displayName: 'OpenCode',
|
||||||
} catch (error) {
|
hookConfigured: true,
|
||||||
if (error.code !== 'ENOENT') throw error;
|
hookScriptExists: true,
|
||||||
await delay(20);
|
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');
|
} finally {
|
||||||
assert.deepEqual(JSON.parse(payload), {
|
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_name: 'bash',
|
||||||
tool_input: { command: 'echo test' },
|
tool_input: { command: 'echo test' },
|
||||||
session_id: 'session-1',
|
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 {
|
} finally {
|
||||||
|
await api.close();
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
await rm(capturePath, { force: true });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('OpenCode status requires both generated runtime scripts', async () => {
|
test('OpenCode bridge forwards both native arguments and surfaces runtime failures', async () => {
|
||||||
const fixture = await createFixture({ guardSource: 'process.exit(0);\n' });
|
const fixture = await createFixture();
|
||||||
|
const guardCapture = path.join(fixture.projectDir, 'guard-input.json');
|
||||||
|
const auditCapture = path.join(fixture.projectDir, 'audit-input.json');
|
||||||
try {
|
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);
|
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 {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user