diff --git a/sdks/node/README.md b/sdks/node/README.md index 41b2584..ec4a1ae 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -95,7 +95,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera | Kiro CLI | `kirocli` | | Kiro IDE | `kiroide` | | OpenCode | `opencode` | -| Copilot CLI | `copilot` | +| GitHub Copilot CLI | `copilot` | | Letta Code | `letta` | ## API Reference diff --git a/sdks/node/src/plugins/copilot-contract.ts b/sdks/node/src/plugins/copilot-contract.ts new file mode 100644 index 0000000..e21f2de --- /dev/null +++ b/sdks/node/src/plugins/copilot-contract.ts @@ -0,0 +1,252 @@ +import os from 'node:os'; +import path from 'node:path'; + +export const AGENT_KEY = 'copilot'; +export const GUARD_SCRIPT = 'guard.js'; +export const AUDIT_SCRIPT = 'hook.js'; +export const HOOK_TIMEOUT_SECONDS = 10; +export const CONFIG_FILE = 'elydora-audit.json'; + +export type JsonObject = Record; +export type CopilotHooks = Record; + +export interface CopilotDocument { + readonly exists: boolean; + readonly filePath: string; + readonly root: JsonObject; + readonly hooks: CopilotHooks; + readonly raw?: string; +} + +export interface CopilotSources { + readonly user: CopilotDocument; + readonly legacy?: CopilotDocument; +} + +export interface RenderedDocument { + readonly document: CopilotDocument; + readonly changed: boolean; + readonly next?: string; +} + +export interface RuntimeContract { + readonly agentId: string; + readonly guardPath: string; + readonly auditPath: string; +} + +interface ParsedArgument { + readonly value: string; + readonly next: number; +} + +export function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function samePath(left: string, right: string): boolean { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + return process.platform === 'win32' + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +function sameAgentId(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quotePowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export function buildHandler(scriptPath: string): JsonObject { + return { + type: 'command', + bash: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`, + powershell: `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`, + timeoutSec: HOOK_TIMEOUT_SECONDS, + }; +} + +function readPosixArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + const apostrophe = `'"'"'`; + let value = ''; + for (let index = start + 1; index < command.length;) { + if (command.startsWith(apostrophe, index)) { + value += "'"; + index += apostrophe.length; + continue; + } + if (command[index] === "'") return { value, next: index + 1 }; + value += command[index]; + index += 1; + } + return undefined; +} + +function parseGeneratedBash(command: unknown): readonly [string, string] | undefined { + if (typeof command !== 'string') return undefined; + const executable = readPosixArgument(command, 0); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readPosixArgument(command, executable.next + 1); + if (!script || script.next !== command.length) return undefined; + return [executable.value, script.value]; +} + +function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + let value = ''; + for (let index = start + 1; index < command.length; index += 1) { + if (command[index] !== "'") { + value += command[index]; + continue; + } + if (command[index + 1] === "'") { + value += "'"; + index += 1; + continue; + } + return { value, next: index + 1 }; + } + return undefined; +} + +function parseGeneratedPowerShell(command: unknown): readonly [string, string] | undefined { + if (typeof command !== 'string' || !command.startsWith('& ')) return undefined; + const executable = readPowerShellArgument(command, 2); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readPowerShellArgument(command, executable.next + 1); + if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined; + return [executable.value, script.value]; +} + +function parseLegacyCommand(command: unknown): string | undefined { + if (typeof command !== 'string') return undefined; + return /^node(?:\.exe)?\s+"([^"]+)"$/i.exec(command)?.[1]; +} + +function managedScriptPath(handler: JsonObject): string | undefined { + if (handler.type !== 'command') return undefined; + const bash = parseGeneratedBash(handler.bash); + const powershell = parseGeneratedPowerShell(handler.powershell); + if (handler.timeoutSec === HOOK_TIMEOUT_SECONDS && bash && powershell) { + if (samePath(bash[0], process.execPath) + && samePath(powershell[0], process.execPath) + && samePath(bash[1], powershell[1])) return bash[1]; + } + const legacyBash = parseLegacyCommand(handler.bash); + const legacyPowerShell = parseLegacyCommand(handler.powershell); + return legacyBash && legacyPowerShell && samePath(legacyBash, legacyPowerShell) + ? legacyBash + : undefined; +} + +function managedAgentId(handler: JsonObject, scriptName: string): string | undefined { + const scriptPath = managedScriptPath(handler); + if (!scriptPath || path.basename(scriptPath) !== scriptName) return undefined; + const agentDirectory = path.dirname(scriptPath); + if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined; + const agentId = path.basename(agentDirectory); + return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined; +} + +function validateHooks(value: unknown, label: string): CopilotHooks { + if (value === undefined) return {}; + if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`); + const hooks: CopilotHooks = {}; + for (const [event, handlers] of Object.entries(value)) { + if (!Array.isArray(handlers)) throw new Error(`${label} field "hooks.${event}" must be an array`); + hooks[event] = handlers.map((handler, index) => { + if (!isObject(handler)) throw new Error(`${label} handler hooks.${event}[${index}] must be an object`); + return handler; + }); + } + return hooks; +} + +export function parseDocument(filePath: string, raw: string, label: string): CopilotDocument { + let value: unknown; + try { + value = JSON.parse(raw); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse ${label} at ${filePath}: ${message}`, { cause: error }); + } + if (!isObject(value)) throw new Error(`${label} at ${filePath} must contain a JSON object`); + if (value.version !== 1) throw new Error(`${label} at ${filePath} must declare version 1`); + return { + exists: true, + filePath, + root: value, + hooks: validateHooks(value.hooks, label), + raw, + }; +} + +export function createDocument(filePath: string): CopilotDocument { + return { exists: false, filePath, root: {}, hooks: {} }; +} + +export function removeManagedHooks(hooks: CopilotHooks, agentId?: string): CopilotHooks { + const next: CopilotHooks = { ...hooks }; + for (const [event, scriptName] of [ + ['preToolUse', GUARD_SCRIPT], + ['postToolUse', AUDIT_SCRIPT], + ] as const) { + const handlers = (next[event] ?? []).filter((handler) => { + const managedId = managedAgentId(handler, scriptName); + return !managedId || (agentId !== undefined && !sameAgentId(managedId, agentId)); + }); + if (handlers.length > 0) next[event] = handlers; + else delete next[event]; + } + return next; +} + +function isEmptyOwnedDocument(root: JsonObject, hooks: CopilotHooks): boolean { + return Object.keys(hooks).length === 0 + && Object.keys(root).every((key) => key === 'version' || key === 'hooks'); +} + +export function renderDocument( + document: CopilotDocument, + hooks: CopilotHooks, +): RenderedDocument { + if (!document.exists && Object.keys(hooks).length === 0) { + return { document, changed: false }; + } + if (document.exists && isEmptyOwnedDocument(document.root, hooks)) { + return { document, changed: true }; + } + const root: JsonObject = { ...document.root, version: 1 }; + if (Object.keys(hooks).length > 0) root.hooks = hooks; + else delete root.hooks; + const next = JSON.stringify(root, null, 2) + '\n'; + return { document, changed: next !== document.raw, next }; +} + +function managedIds(handlers: JsonObject[], scriptName: string): Set { + return new Set(handlers.flatMap((handler) => { + const agentId = managedAgentId(handler, scriptName); + return agentId ? [agentId] : []; + })); +} + +export function runtimeContracts(hooks: CopilotHooks): RuntimeContract[] { + const guards = managedIds(hooks.preToolUse ?? [], GUARD_SCRIPT); + const audits = managedIds(hooks.postToolUse ?? [], AUDIT_SCRIPT); + const root = path.join(os.homedir(), '.elydora'); + return [...guards] + .filter((agentId) => [...audits].some((auditId) => sameAgentId(agentId, auditId))) + .map((agentId) => ({ + agentId, + guardPath: path.join(root, agentId, GUARD_SCRIPT), + auditPath: path.join(root, agentId, AUDIT_SCRIPT), + })); +} diff --git a/sdks/node/src/plugins/copilot-io.ts b/sdks/node/src/plugins/copilot-io.ts new file mode 100644 index 0000000..a64df9c --- /dev/null +++ b/sdks/node/src/plugins/copilot-io.ts @@ -0,0 +1,281 @@ +import { randomUUID } from 'node:crypto'; +import fsp from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + AGENT_KEY, + CONFIG_FILE, + type CopilotDocument, + type CopilotSources, + type JsonObject, + type RenderedDocument, + type RuntimeContract, + createDocument, + isObject, + parseDocument, +} from './copilot-contract.js'; + +interface FileChange { + readonly filePath: string; + readonly label: string; + readonly original?: string; + readonly next?: string; +} + +interface StagedChange { + readonly change: FileChange; + readonly tempPath?: string; + readonly rollbackPath?: string; + committed: boolean; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return isObject(error) && error.code === code; +} + +function paths(): { readonly user: string; readonly legacy: string } { + const override = process.env.COPILOT_HOME?.trim(); + const copilotHome = override || path.join(os.homedir(), '.copilot'); + return { + user: path.join(copilotHome, 'hooks', CONFIG_FILE), + legacy: path.join(process.cwd(), '.github', 'hooks', 'hooks.json'), + }; +} + +async function readOptional(filePath: string, label: string): Promise { + try { + return await fsp.readFile(filePath, 'utf-8'); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return undefined; + throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } +} + +async function readDocument(filePath: string, label: string): Promise { + const raw = await readOptional(filePath, label); + return raw === undefined ? undefined : parseDocument(filePath, raw, label); +} + +export async function readSources(): Promise { + const configPaths = paths(); + const [user, legacy] = await Promise.all([ + readDocument(configPaths.user, 'GitHub Copilot user hooks'), + readDocument(configPaths.legacy, 'GitHub Copilot legacy project hooks'), + ]); + return { + user: user ?? createDocument(configPaths.user), + legacy, + }; +} + +function changeFor(rendered: RenderedDocument): FileChange | undefined { + if (!rendered.changed) return undefined; + return { + filePath: rendered.document.filePath, + label: 'GitHub Copilot hook source', + original: rendered.document.raw, + next: rendered.next, + }; +} + +async function unlinkOptional(filePath: string): Promise { + try { + await fsp.unlink(filePath); + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) throw error; + } +} + +async function writeExclusive(filePath: string, contents: string, label: string): Promise { + let handle: FileHandle | undefined; + try { + handle = await fsp.open(filePath, 'wx', 0o600); + await handle.writeFile(contents, 'utf-8'); + await handle.sync(); + await handle.close(); + handle = undefined; + } catch (error) { + const failures = [asError(error)]; + if (handle) { + try { + await handle.close(); + } catch (closeError) { + failures.push(asError(closeError)); + } + } + try { + await unlinkOptional(filePath); + } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + if (failures.length > 1) throw new AggregateError(failures, `Stage ${label}`); + throw new Error(`Stage ${label}: ${errorMessage(error)}`, { cause: failures[0] }); + } +} + +async function assertUnchanged(change: FileChange): Promise { + const current = await readOptional(change.filePath, change.label); + if (current !== change.original) { + throw new Error(`${change.label} changed during Copilot hook update: ${change.filePath}`); + } +} + +async function stage(change: FileChange): Promise { + await assertUnchanged(change); + const directory = path.dirname(change.filePath); + await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); + const token = randomUUID(); + const tempPath = change.next === undefined + ? undefined + : path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`); + const rollbackPath = change.original === undefined + ? undefined + : path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`); + try { + if (tempPath) await writeExclusive(tempPath, change.next!, change.label); + if (rollbackPath && change.next !== undefined) { + await writeExclusive(rollbackPath, change.original!, `${change.label} rollback`); + } + } catch (error) { + const failures = [asError(error)]; + for (const stagedPath of [tempPath, rollbackPath]) { + if (!stagedPath) continue; + try { + await unlinkOptional(stagedPath); + } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + } + if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`); + throw error; + } + return { change, tempPath, rollbackPath, committed: false }; +} + +async function commit(staged: StagedChange): Promise { + await assertUnchanged(staged.change); + if (staged.change.next === undefined) { + if (!staged.rollbackPath) throw new Error(`Missing rollback path for ${staged.change.label}`); + await fsp.rename(staged.change.filePath, staged.rollbackPath); + } else { + if (!staged.tempPath) throw new Error(`Missing staged file for ${staged.change.label}`); + await fsp.rename(staged.tempPath, staged.change.filePath); + } + staged.committed = true; +} + +async function rollback(staged: StagedChange): Promise { + if (!staged.committed) return; + if (staged.change.original === undefined) { + await unlinkOptional(staged.change.filePath); + return; + } + if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`); + await fsp.rename(staged.rollbackPath, staged.change.filePath); +} + +async function cleanup(staged: StagedChange): Promise { + const files = [staged.tempPath, staged.rollbackPath].filter( + (filePath): filePath is string => filePath !== undefined, + ); + await Promise.all(files.map(unlinkOptional)); +} + +export async function writeDocuments(rendered: RenderedDocument[]): Promise { + const changes = rendered.flatMap((document) => { + const change = changeFor(document); + return change ? [change] : []; + }); + const staged: StagedChange[] = []; + try { + for (const change of changes) staged.push(await stage(change)); + for (const item of staged) await commit(item); + } catch (error) { + const failures = [asError(error)]; + for (const item of [...staged].reverse()) { + try { + await rollback(item); + } catch (rollbackError) { + failures.push(asError(rollbackError)); + } + } + for (const item of staged) { + try { + await cleanup(item); + } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + } + throw new AggregateError(failures, `Write GitHub Copilot hook sources: ${errorMessage(error)}`); + } + const cleanupErrors: Error[] = []; + for (const item of staged) { + try { + await cleanup(item); + } catch (error) { + cleanupErrors.push(asError(error)); + } + } + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, 'Clean GitHub Copilot hook transaction files'); + } +} + +export async function regularFileExists(filePath: string, label: string): Promise { + try { + return (await fsp.stat(filePath)).isFile(); + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false; + throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } +} + +export async function requireRuntime(filePath: string, label: string): Promise { + if (!filePath) throw new Error(`${label} path is required`); + if (!await regularFileExists(filePath, label)) throw new Error(`${label} is missing: ${filePath}`); +} + +async function readRuntimeConfig(filePath: string): Promise { + const raw = await readOptional(filePath, 'Elydora runtime config'); + if (raw === undefined) return undefined; + let value: unknown; + try { + value = JSON.parse(raw); + } catch (error) { + throw new Error(`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!isObject(value)) throw new Error(`Elydora runtime config at ${filePath} must contain a JSON object`); + return value; +} + +export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise { + for (const contract of contracts) { + const runtimeConfig = await readRuntimeConfig( + path.join(path.dirname(contract.guardPath), 'config.json'), + ); + if (!runtimeConfig + || runtimeConfig.agent_name !== AGENT_KEY + || runtimeConfig.agent_id !== contract.agentId) continue; + const files = await Promise.all([ + regularFileExists(contract.guardPath, 'Elydora guard runtime'), + regularFileExists(contract.auditPath, 'Elydora audit runtime'), + ]); + if (files.every(Boolean)) return true; + } + return false; +} diff --git a/sdks/node/src/plugins/copilot.ts b/sdks/node/src/plugins/copilot.ts index b5870b1..d027e18 100644 --- a/sdks/node/src/plugins/copilot.ts +++ b/sdks/node/src/plugins/copilot.ts @@ -1,164 +1,89 @@ -import fsp from 'node:fs/promises'; -import path from 'node:path'; import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, + buildHandler, + removeManagedHooks, + renderDocument, + runtimeContracts, +} from './copilot-contract.js'; +import { + readSources, + requireRuntime, + runtimeFilesExist, + writeDocuments, +} from './copilot-io.js'; import { SUPPORTED_AGENTS } from './registry.js'; -const AGENT_KEY = 'copilot'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; -function resolveConfigDir(): string { - return path.join(process.cwd(), entry.configDir); -} - -function resolveConfigPath(): string { - return path.join(resolveConfigDir(), entry.configFile); -} - -function buildHookCommand(scriptPath: string): string { - return `node "${scriptPath}"`; -} - -function isElydoraCommand(cmd: string, agentId?: string): boolean { - if (!cmd.includes('elydora')) return false; - if (agentId) { - return cmd.includes(agentId); - } - return true; -} - -function filterElydoraEntries(arr: Array>, agentId?: string): Array> { - return arr.filter((h) => { - if (typeof h.bash === 'string' && isElydoraCommand(h.bash, agentId)) return false; - if (typeof h.powershell === 'string' && isElydoraCommand(h.powershell, agentId)) return false; - return true; - }); +function mergedContracts( + userHooks: Parameters[0], + legacyHooks?: Parameters[0], +): ReturnType { + const contracts = [ + ...runtimeContracts(userHooks), + ...runtimeContracts(legacyHooks ?? {}), + ]; + const unique = new Map(contracts.map((contract) => [contract.agentId, contract])); + return [...unique.values()]; } export const copilotPlugin: AgentPlugin = { async install(config: InstallConfig): Promise { - const configDir = resolveConfigDir(); - await fsp.mkdir(configDir, { recursive: true }); + if (!config.agentId) throw new Error('agentId is required'); + const sources = await readSources(); + await requireRuntime(config.guardScriptPath, 'Elydora guard runtime'); + await requireRuntime(config.hookScriptPath, 'Elydora audit runtime'); - const configPath = resolveConfigPath(); - let settings: Record = { version: 1 }; + const userHooks = removeManagedHooks(sources.user.hooks); + userHooks.preToolUse = [ + ...(userHooks.preToolUse ?? []), + buildHandler(config.guardScriptPath), + ]; + userHooks.postToolUse = [ + ...(userHooks.postToolUse ?? []), + buildHandler(config.hookScriptPath), + ]; - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - settings = JSON.parse(raw); - } catch { - // Start fresh + const rendered = [renderDocument(sources.user, userHooks)]; + if (sources.legacy) { + rendered.push(renderDocument( + sources.legacy, + removeManagedHooks(sources.legacy.hooks), + )); } - - settings.version = 1; - - if (!settings.hooks || typeof settings.hooks !== 'object') { - settings.hooks = {}; - } - const hooks = settings.hooks as Record; - - // --- preToolUse (guard — freeze enforcement) --- - if (!Array.isArray(hooks.preToolUse)) { - hooks.preToolUse = []; - } - const preFiltered = filterElydoraEntries(hooks.preToolUse as Array>); - preFiltered.push({ - type: 'command', - bash: buildHookCommand(config.guardScriptPath), - powershell: buildHookCommand(config.guardScriptPath), - timeoutSec: 5, - }); - hooks.preToolUse = preFiltered; - - // --- postToolUse (audit logging) --- - if (!Array.isArray(hooks.postToolUse)) { - hooks.postToolUse = []; - } - const postFiltered = filterElydoraEntries(hooks.postToolUse as Array>); - postFiltered.push({ - type: 'command', - bash: buildHookCommand(config.hookScriptPath), - powershell: buildHookCommand(config.hookScriptPath), - timeoutSec: 5, - }); - hooks.postToolUse = postFiltered; - - settings.hooks = hooks; - await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await writeDocuments(rendered); + console.log(` GitHub Copilot CLI hooks: ${sources.user.filePath}`); }, async uninstall(agentId?: string): Promise { - const configPath = resolveConfigPath(); - - let settings: Record; - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - settings = JSON.parse(raw); - } catch { - return; + const sources = await readSources(); + const rendered = [ + renderDocument(sources.user, removeManagedHooks(sources.user.hooks, agentId)), + ]; + if (sources.legacy) { + rendered.push(renderDocument( + sources.legacy, + removeManagedHooks(sources.legacy.hooks, agentId), + )); } - - const hooks = settings.hooks as Record | undefined; - if (!hooks) return; - - if (Array.isArray(hooks.preToolUse)) { - hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array>, agentId); - } - if (Array.isArray(hooks.postToolUse)) { - hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array>, agentId); - } - - await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await writeDocuments(rendered); }, async status(): Promise { - const configPath = resolveConfigPath(); - - let hookConfigured = false; - let hookScriptPath = ''; - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - const settings = JSON.parse(raw); - const hooks = settings.hooks as Record | undefined; - if (hooks) { - const check = (arr: unknown) => - Array.isArray(arr) && (arr as Array>).some( - (h) => - (typeof h.bash === 'string' && h.bash.includes('elydora')) || - (typeof h.powershell === 'string' && h.powershell.includes('elydora')), - ); - hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse); - - // Extract hook script path from postToolUse bash/powershell command - if (hookConfigured && Array.isArray(hooks.postToolUse)) { - for (const h of hooks.postToolUse as Array>) { - const cmd = (h.bash || h.powershell) as string; - if (cmd && cmd.includes('elydora')) { - hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); - } - } - } - } - } catch { - // Config not readable - } - - let hookScriptExists = false; - if (hookScriptPath) { - try { - await fsp.access(hookScriptPath); - hookScriptExists = true; - } catch { - // File doesn't exist - } - } - + const sources = await readSources(); + const contracts = mergedContracts(sources.user.hooks, sources.legacy?.hooks); + const hookConfigured = contracts.length > 0; + const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts); return { installed: hookConfigured && hookScriptExists, agentName: AGENT_KEY, displayName: entry.name, hookConfigured, hookScriptExists, - configPath, + configPath: sources.user.filePath, }; }, }; diff --git a/sdks/node/src/plugins/registry.ts b/sdks/node/src/plugins/registry.ts index 7a4c323..9228d3a 100644 --- a/sdks/node/src/plugins/registry.ts +++ b/sdks/node/src/plugins/registry.ts @@ -12,7 +12,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap = new Map ['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }], ['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }], ['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }], - ['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }], + ['copilot', { name: 'GitHub Copilot CLI', configDir: '~/.copilot/hooks', configFile: 'elydora-audit.json' }], ['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }], ['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }], ['cline', { name: 'Cline', configDir: '~/.cline/hooks', configFile: 'PreToolUse.mjs' }], diff --git a/sdks/node/test/copilot-plugin.test.mjs b/sdks/node/test/copilot-plugin.test.mjs new file mode 100644 index 0000000..25c1121 --- /dev/null +++ b/sdks/node/test/copilot-plugin.test.mjs @@ -0,0 +1,389 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { pathToFileURL } from 'node:url'; + +const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/copilot.js')).href; +const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; + +function runNode(args, env, cwd, input = '') { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ['pipe', '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 })); + child.stdin.end(input); + }); +} + +function runHook(handler, input) { + const command = process.platform === 'win32' + ? ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', handler.powershell]] + : ['/bin/sh', ['-c', handler.bash]]; + return new Promise((resolve, reject) => { + const child = spawn(command[0], command[1], { stdio: ['pipe', '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 })); + child.stdin.end(input); + }); +} + +async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, typeof value === 'string' ? value : JSON.stringify(value, null, 2)); +} + +async function runPlugin(fixture, method, argument) { + const source = ` + import { copilotPlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await copilotPlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + return runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + COPILOT_HOME: fixture.copilotHome, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }, + fixture.projectDir, + ); +} + +async function createFixture({ + userConfig, + legacyConfig, + guardSource, + hookSource, + createRuntimes = true, +} = {}) { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-copilot-')); + const projectDir = path.join(homeDir, 'project'); + const copilotHome = path.join(homeDir, 'custom-copilot'); + const configPath = path.join(copilotHome, 'hooks', 'elydora-audit.json'); + const legacyPath = path.join(projectDir, '.github', 'hooks', 'hooks.json'); + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + await mkdir(projectDir, { recursive: true }); + if (createRuntimes) { + await mkdir(agentDir, { recursive: true }); + await writeFile( + guardScriptPath, + guardSource ?? "process.stdin.resume(); process.stdin.once('end', () => { process.stderr.write('Agent is frozen by Elydora.'); process.exit(2); });\n", + ); + await writeFile(hookScriptPath, hookSource ?? 'process.stdin.resume();\n'); + await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({ + agent_id: 'agent-1', + agent_name: 'copilot', + })); + } + if (userConfig !== undefined) await writeJson(configPath, userConfig); + if (legacyConfig !== undefined) await writeJson(legacyPath, legacyConfig); + return { + agentDir, + configPath, + copilotHome, + guardScriptPath, + homeDir, + hookScriptPath, + legacyPath, + projectDir, + async install() { + return runPlugin(this, 'install', { + agentName: 'copilot', + agentId: 'agent-1', + guardScriptPath, + hookScriptPath, + }); + }, + async close() { + await rm(homeDir, { recursive: true, force: true }); + }, + }; +} + +function managedHandler(config, event, scriptName) { + return config.hooks?.[event]?.find( + (handler) => handler.bash?.includes(scriptName) || handler.powershell?.includes(scriptName), + ); +} + +function assertNativeHandler(handler) { + assert.deepEqual(Object.keys(handler).sort(), ['bash', 'powershell', 'timeoutSec', 'type']); + assert.equal(handler.type, 'command'); + assert.equal(handler.timeoutSec, 10); + assert.match(handler.bash, /node(?:\.exe)?/i); + assert.match(handler.powershell, /^& /); +} + +function legacyManagedConfig(fixture, extraHooks = {}) { + return { + version: 1, + hooks: { + preToolUse: [{ + type: 'command', + bash: `node "${fixture.guardScriptPath}"`, + powershell: `node "${fixture.guardScriptPath}"`, + timeoutSec: 5, + }], + postToolUse: [{ + type: 'command', + bash: `node "${fixture.hookScriptPath}"`, + powershell: `node "${fixture.hookScriptPath}"`, + timeoutSec: 5, + }], + ...extraHooks, + }, + }; +} + +test('GitHub Copilot CLI is registered with the native user hook file', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('copilot'), { + name: 'GitHub Copilot CLI', + configDir: '~/.copilot/hooks', + configFile: 'elydora-audit.json', + }); +}); + +test('Copilot install preserves user hooks, migrates legacy entries, and is idempotent', async () => { + const fixture = await createFixture({ + userConfig: { + version: 1, + disableAllHooks: false, + hooks: { + sessionStart: [{ type: 'command', command: 'user-session-hook' }], + preToolUse: [{ type: 'command', command: 'user-pre-hook' }], + }, + }, + }); + try { + await writeJson(fixture.legacyPath, legacyManagedConfig(fixture, { + notification: [{ type: 'command', command: 'user-notification-hook' }], + })); + + const first = await fixture.install(); + assert.equal(first.code, 0, first.stderr); + const second = await fixture.install(); + assert.equal(second.code, 0, second.stderr); + + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal(config.version, 1); + assert.equal(config.disableAllHooks, false); + assert.deepEqual(config.hooks.sessionStart, [{ type: 'command', command: 'user-session-hook' }]); + assert.deepEqual(config.hooks.preToolUse[0], { type: 'command', command: 'user-pre-hook' }); + assert.equal(config.hooks.preToolUse.length, 2); + assert.equal(config.hooks.postToolUse.length, 1); + assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js')); + assertNativeHandler(managedHandler(config, 'postToolUse', 'hook.js')); + + const legacy = JSON.parse(await readFile(fixture.legacyPath, 'utf-8')); + assert.deepEqual(legacy, { + version: 1, + hooks: { + notification: [{ type: 'command', command: 'user-notification-hook' }], + }, + }); + } finally { + await fixture.close(); + } +}); + +test('Copilot migration removes a legacy file owned entirely by Elydora', async () => { + const fixture = await createFixture(); + try { + await writeJson(fixture.legacyPath, legacyManagedConfig(fixture)); + const result = await fixture.install(); + assert.equal(result.code, 0, result.stderr); + await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' }); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js')); + } finally { + await fixture.close(); + } +}); + +test('Copilot commands block freezes and forward the native payload byte-for-byte', async () => { + const capturePath = path.join(os.tmpdir(), `elydora-copilot-event-${process.pid}-${Date.now()}.json`); + const fixture = await createFixture({ + 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))); + `, + }); + try { + const install = await fixture.install(); + assert.equal(install.code, 0, install.stderr); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const guard = managedHandler(config, 'preToolUse', 'guard.js'); + const audit = managedHandler(config, 'postToolUse', 'hook.js'); + const prePayload = JSON.stringify({ + sessionId: 'session-1', + timestamp: Date.now(), + cwd: fixture.projectDir, + toolName: 'powershell', + toolArgs: { command: 'Get-ChildItem' }, + }); + const guardResult = await runHook(guard, prePayload); + assert.equal(guardResult.code, 2, guardResult.stderr); + assert.match(guardResult.stderr, /Agent is frozen by Elydora/); + + const postPayload = JSON.stringify({ + sessionId: 'session-1', + timestamp: Date.now(), + cwd: fixture.projectDir, + toolName: 'powershell', + toolArgs: { command: 'Get-ChildItem' }, + toolResult: { output: 'ok' }, + }); + const auditResult = await runHook(audit, postPayload); + assert.equal(auditResult.code, 0, auditResult.stderr); + assert.equal(await readFile(capturePath, 'utf-8'), postPayload); + } finally { + await fixture.close(); + await rm(capturePath, { force: true }); + } +}); + +test('Copilot treats an empty home override as the official default', async () => { + const fixture = await createFixture(); + try { + fixture.copilotHome = ''; + const result = await fixture.install(); + assert.equal(result.code, 0, result.stderr); + const defaultPath = path.join(fixture.homeDir, '.copilot', 'hooks', 'elydora-audit.json'); + const config = JSON.parse(await readFile(defaultPath, 'utf-8')); + assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js')); + const status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).configPath, defaultPath); + } finally { + await fixture.close(); + } +}); + +test('Copilot status requires a complete pair, matching runtime identity, and both scripts', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + let status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 0, status.stderr); + assert.equal(JSON.parse(status.stdout).installed, true); + + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + delete config.hooks.postToolUse; + await writeJson(fixture.configPath, config); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).hookConfigured, false); + + assert.equal((await fixture.install()).code, 0); + await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); + status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /parse Elydora runtime config/i); + } finally { + await fixture.close(); + } +}); + +test('Copilot uninstall removes exact ownership and preserves user entries', async () => { + const fixture = await createFixture({ + userConfig: { version: 1, hooks: { notification: [{ type: 'command', command: 'keep' }] } }, + }); + try { + assert.equal((await fixture.install()).code, 0); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + config.hooks.preToolUse.push({ + type: 'command', + bash: `'${process.execPath}' '${path.join(fixture.homeDir, '.elydora', 'agent-10', 'guard.js')}'`, + powershell: 'user-decoy', + timeoutSec: 10, + }); + await writeJson(fixture.configPath, config); + + const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.deepEqual(remaining.hooks.notification, [{ type: 'command', command: 'keep' }]); + assert.equal(remaining.hooks.postToolUse, undefined); + assert.equal(remaining.hooks.preToolUse.length, 1); + assert.match(remaining.hooks.preToolUse[0].bash, /agent-10/); + } finally { + await fixture.close(); + } +}); + +test('Copilot uninstall leaves absent hook sources absent', async () => { + const fixture = await createFixture(); + try { + const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + await assert.rejects(readFile(fixture.legacyPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Copilot preserves malformed and invalid configs for recovery', async () => { + for (const existing of [ + '{ malformed', + { hooks: {} }, + { version: 2, hooks: {} }, + { version: 1, hooks: null }, + { version: 1, hooks: { preToolUse: null } }, + { version: 1, hooks: { preToolUse: [null] } }, + ]) { + const fixture = await createFixture({ userConfig: existing }); + try { + const before = await readFile(fixture.configPath, 'utf-8'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.equal(await readFile(fixture.configPath, 'utf-8'), before); + } finally { + await fixture.close(); + } + } +}); + +test('Copilot rejects missing runtimes before creating hook config', async () => { + const fixture = await createFixture({ createRuntimes: false }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /runtime is missing/i); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Copilot atomic writes leave no transaction files', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const files = await readdir(path.dirname(fixture.configPath)); + assert.equal(files.some((name) => name.endsWith('.tmp') || name.endsWith('.rollback')), false); + } finally { + await fixture.close(); + } +});