From af8d227b457c49a658e0cc00a086471a5f361748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Jul 2026 12:09:24 -0400 Subject: [PATCH] fix(copilot): mirror transactional Node hooks --- AGENTS.md | 2 +- integrations/README.md | 2 +- integrations/catalog.json | 14 +- sdks/node/README.md | 2 + sdks/node/src/plugins/copilot-contract.ts | 186 +++--- sdks/node/src/plugins/copilot-installation.ts | 145 +++++ sdks/node/src/plugins/copilot-io.ts | 437 ++++++------- sdks/node/src/plugins/copilot-schema.ts | 162 +++++ sdks/node/src/plugins/copilot.ts | 117 ++-- sdks/node/src/plugins/managed-installation.ts | 10 +- sdks/node/src/plugins/managed-transaction.ts | 45 +- sdks/node/src/plugins/strict-json.ts | 35 +- .../test-support/copilot-test-helpers.mjs | 227 +++++++ sdks/node/test/copilot-official.test.mjs | 81 +++ sdks/node/test/copilot-plugin.test.mjs | 593 ++++++++++-------- sdks/node/test/copilot-transaction.test.mjs | 328 ++++++++++ test/integration-catalog-contracts.test.mjs | 3 + 17 files changed, 1738 insertions(+), 651 deletions(-) create mode 100644 sdks/node/src/plugins/copilot-installation.ts create mode 100644 sdks/node/src/plugins/copilot-schema.ts create mode 100644 sdks/node/test-support/copilot-test-helpers.mjs create mode 100644 sdks/node/test/copilot-official.test.mjs create mode 100644 sdks/node/test/copilot-transaction.test.mjs diff --git a/AGENTS.md b/AGENTS.md index d5e82e7..a66c475 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ This repository publishes the open-source server, console, integration catalog, - All three SDKs commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Their status checks require exact runtime identity, canonical private keys, physical files, and exact wrapper sources. Installation validates matcher syntax with JavaScript `RegExp`; status and uninstall stay available for offline recovery. Verify the effective configuration with `auggie tools list`. - Treat Cline 3 CLI file hooks as the SDK adapter contract. Resolve hooks additively from `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, `.clinerules/hooks`, and `.cline/hooks`; write only the `$CLINE_DIR` source. Preserve the complete hybrid CLI payload and use supported event filename extensions. Commit both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Status requires physical files, canonical private keys, exact runtime identity, and exact generated runtime and wrapper sources. Node and Go wrappers use `process.execPath`; Python wrappers resolve the runtime executable from an absolute shebang. Translate guard exit code `2` into one-line JSON stdout with `cancel: true`; hook errors, timeouts, and invalid control JSON fail open. Model Cline IDE as a separate variant with runtime `--hooks-dir`, Documents, and `.clinerules/hooks` roots, exact `.ps1` filenames on Windows, and executable extensionless filenames on macOS and Linux. - Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Model user JSON, inline TOML layers, project files, managed `requirements.toml`, and plugin-bundled hooks as additive stable sources. SDK adapters write only the user JSON source, register exact `PreToolUse` and `PostToolUse` match-all command groups, preserve the complete native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, commit user hooks and all four runtime artifacts through one rollback-capable transaction, and require `/hooks` trust review for both definition hashes. -- Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction. +- Resolve GitHub Copilot CLI user hooks through `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default, and treat an empty override as the default. Keep policy, repository, inline settings, cross-tool Claude, and plugin hook sources read-only. Evaluate `disableAllHooks` through legacy user config, user settings, Claude repository and local settings, then GitHub repository and local settings; the managed hook file flag remains independently authoritative. Register exact `preToolUse`, `postToolUse`, and `postToolUseFailure` command handlers with ten-second timeouts, preserve the complete native camelCase payload, and propagate frozen or revoked state through exit code `2`. Commit each SDK's user hook file, exact legacy migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require physical source paths, strict hook schemas, exact runtime sources, canonical private keys, and matching runtime identity during status checks, then verify discovery with the current official Copilot native hook loader and restart active CLI sessions. - Model Cursor as one CLI-and-IDE hook contract. Preserve user, project, and enterprise sources in documented priority order; forward `tool_name`, `tool_input`, and `conversation_id`; use exit code `2` for denial; record `failClosed` as the per-script failure-mode control. - Write Node Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction. - Write Go Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction. diff --git a/integrations/README.md b/integrations/README.md index 9eefa9b..a1cbcf3 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -8,7 +8,7 @@ Grok Build adapters write exact `PreToolUse`, `PostToolUse`, and `PostToolUseFai Cline CLI 3 and Cline IDE use separate file-hook loaders. SDK adapters target CLI 3, write only `$CLINE_DIR/hooks`, preserve its hybrid `tool_call`/`preToolUse` and `tool_result`/`postToolUse` payload, and emit pure JSON cancellation controls. The IDE variant discovers runtime `--hooks-dir`, Documents, and `.clinerules/hooks` roots; Windows selects exact `.ps1` files and macOS/Linux select executable extensionless files. Both runtimes continue after hook crashes, timeouts, and invalid controls while recording the failure. -GitHub Copilot command `preToolUse` hooks deny on crashes and every non-zero exit. Command timeouts continue through the normal permission flow, so `timeout_failure_mode` records that explicit exception when it differs from `failure_mode`. +GitHub Copilot CLI loads user hook files from `$COPILOT_HOME/hooks` or `~/.copilot/hooks` and combines them with policy, repository, inline settings, cross-tool Claude, and plugin sources. Elydora owns one user file with exact `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers and preserves native camelCase fields, including `toolResult` and `error`. Effective settings precedence controls `disableAllHooks`; each managed file also retains its independent disable flag. Command crashes and non-zero exits deny pre-tool execution, while command timeouts continue through the normal permission flow. Cursor uses the same `preToolUse` and `postToolUse` contract across its CLI and IDE. User, project, and enterprise hook sources merge by documented priority; command exit code `2` denies an action, while per-script `failClosed` controls crash, timeout, and invalid-output behavior. diff --git a/integrations/catalog.json b/integrations/catalog.json index a2c122a..59b7b3e 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -163,8 +163,18 @@ "~/.copilot/hooks/*.json", ".github/hooks/*.json" ], - "events": { "before_tool": "preToolUse", "after_tool": "postToolUse" }, - "event_fields": { "name": "toolName", "input": "toolArgs", "session": "sessionId" }, + "events": { + "before_tool": "preToolUse", + "after_tool": "postToolUse", + "after_tool_failure": "postToolUseFailure" + }, + "event_fields": { + "name": "toolName", + "input": "toolArgs", + "session": "sessionId", + "output": "toolResult", + "error": "error" + }, "blocking": { "mechanism": "any_nonzero_exit", "failure_mode": "fail_closed", diff --git a/sdks/node/README.md b/sdks/node/README.md index 4003495..da58310 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -57,6 +57,8 @@ Claude Code installation writes exact matchless `PreToolUse`, `PostToolUse`, and Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes. +GitHub Copilot CLI installation writes exact `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `$COPILOT_HOME/hooks/elydora-audit.json` (`~/.copilot/hooks/elydora-audit.json` by default). Empty home overrides select the default. Policy, repository, inline settings, cross-tool Claude, and plugin hook sources remain unchanged and continue loading additively. Elydora evaluates the effective `disableAllHooks` value across Copilot's documented settings precedence before writing. The managed hook file, exact legacy migration, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed commands preserve the complete native camelCase payload and propagate freeze or revocation through exit code `2`; Copilot timeouts retain their documented fail-open behavior. Restart active Copilot CLI sessions after installation. + Cursor installation writes native `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json` for both the CLI and IDE. Project and enterprise hook sources remain unchanged. The installation commits Cursor's hook file, generated runtimes, runtime config, and private key as one rollback-capable update. Managed commands use the current Node.js executable, preserve the complete Cursor JSON payload, return valid JSON, propagate freeze and revocation exit code `2`, and enable `failClosed` with a ten-second timeout. Gemini CLI installation writes named, matchless `BeforeTool` and `AfterTool` command hooks to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Empty home overrides select the OS home; relative and literal-tilde values retain Gemini CLI's native path behavior. JSON comments and unrelated settings remain intact. Workspace, system-default, system-override, and extension hook sources remain unchanged and continue loading additively. User settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed handlers preserve the complete native snake_case payload, emit valid JSON on successful execution, propagate freeze and revocation through exit code `2`, and use encoded PowerShell commands on Windows. `hooksConfig.enabled` and `hooksConfig.disabled` remain authoritative. Run `/hooks list` after installation to inspect effective hooks from every source. diff --git a/sdks/node/src/plugins/copilot-contract.ts b/sdks/node/src/plugins/copilot-contract.ts index e21f2de..13d66d7 100644 --- a/sdks/node/src/plugins/copilot-contract.ts +++ b/sdks/node/src/plugins/copilot-contract.ts @@ -1,5 +1,8 @@ import os from 'node:os'; import path from 'node:path'; +import type { FileSnapshot } from './managed-files.js'; +import { validateHooks, type CopilotHooks } from './copilot-schema.js'; +import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; export const AGENT_KEY = 'copilot'; export const GUARD_SCRIPT = 'guard.js'; @@ -7,20 +10,33 @@ 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; +const MANAGED_EVENTS = [ + ['preToolUse', GUARD_SCRIPT], + ['postToolUse', AUDIT_SCRIPT], + ['postToolUseFailure', AUDIT_SCRIPT], +] as const; export interface CopilotDocument { readonly exists: boolean; readonly filePath: string; readonly root: JsonObject; readonly hooks: CopilotHooks; + readonly hooksDisabled: boolean; readonly raw?: string; + readonly snapshot?: FileSnapshot; } export interface CopilotSources { readonly user: CopilotDocument; readonly legacy?: CopilotDocument; + readonly disabledBy?: string; + readonly settingsPreconditions: readonly CopilotSourcePrecondition[]; +} + +export interface CopilotSourcePrecondition { + readonly filePath: string; + readonly label: string; + readonly snapshot?: FileSnapshot; } export interface RenderedDocument { @@ -40,11 +56,12 @@ interface ParsedArgument { readonly next: number; } -export function isObject(value: unknown): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); +interface ManagedEntry { + readonly agentId: string; + readonly scriptPath: string; } -function samePath(left: string, right: string): boolean { +export function samePath(left: string, right: string): boolean { const normalizedLeft = path.resolve(left); const normalizedRight = path.resolve(right); return process.platform === 'win32' @@ -52,8 +69,14 @@ function samePath(left: string, right: string): boolean { : normalizedLeft === normalizedRight; } -function sameAgentId(left: string, right: string): boolean { - return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +export function sameAgentId(left: string, right: string): boolean { + return process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function agentKey(agentId: string): string { + return process.platform === 'win32' ? agentId.toLowerCase() : agentId; } function quotePosix(value: string): string { @@ -126,82 +149,88 @@ function parseGeneratedPowerShell(command: unknown): readonly [string, string] | return [executable.value, script.value]; } +function exactHandlerKeys(handler: JsonObject): boolean { + return Object.keys(handler).sort().join(',') === 'bash,powershell,timeoutSec,type'; +} + +function nodeExecutable(filePath: string): boolean { + if (!path.isAbsolute(filePath)) return false; + const name = path.basename(filePath).toLowerCase(); + return name === 'node' || name === 'node.exe'; +} + +function currentManagedScriptPath(handler: JsonObject): string | undefined { + if (!exactHandlerKeys(handler) + || handler.type !== 'command' + || handler.timeoutSec !== HOOK_TIMEOUT_SECONDS) return undefined; + const bash = parseGeneratedBash(handler.bash); + const powershell = parseGeneratedPowerShell(handler.powershell); + if (!bash || !powershell + || !nodeExecutable(bash[0]) + || !samePath(bash[0], powershell[0]) + || !samePath(bash[1], powershell[1])) return undefined; + return bash[1]; +} + 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 legacyManagedScriptPath(handler: JsonObject): string | undefined { + if (!exactHandlerKeys(handler) + || handler.type !== 'command' + || handler.timeoutSec !== 5) return undefined; + const bash = parseLegacyCommand(handler.bash); + const powershell = parseLegacyCommand(handler.powershell); + return bash && powershell && samePath(bash, powershell) ? bash : undefined; } -function managedAgentId(handler: JsonObject, scriptName: string): string | undefined { - const scriptPath = managedScriptPath(handler); - if (!scriptPath || path.basename(scriptPath) !== scriptName) return undefined; +function managedEntry(handler: JsonObject, scriptName: string): ManagedEntry | undefined { + const scriptPath = currentManagedScriptPath(handler) ?? legacyManagedScriptPath(handler); + if (!scriptPath || path.basename(scriptPath).toLowerCase() !== scriptName.toLowerCase()) { + return undefined; + } const agentDirectory = path.dirname(scriptPath); - if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined; + if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { + return undefined; + } const agentId = path.basename(agentDirectory); - return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined; + if (!agentId || agentId === '.' || agentId === '..') return undefined; + return { agentId, scriptPath }; } -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; - }); +export function parseDocument( + filePath: string, + snapshot: FileSnapshot, + label: string, +): CopilotDocument { + const root = parseStrictJsonObject(snapshot.contents, `${label} at ${filePath}`); + if (root.version !== 1) throw new Error(`${label} at ${filePath} must declare version 1`); + if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') { + throw new Error(`${label} at ${filePath} field "disableAllHooks" must be a boolean`); } - 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, + root, + hooks: validateHooks(root.hooks, `${label} at ${filePath}`), + hooksDisabled: root.disableAllHooks === true, + raw: snapshot.contents, + snapshot, }; } export function createDocument(filePath: string): CopilotDocument { - return { exists: false, filePath, root: {}, hooks: {} }; + return { exists: false, filePath, root: {}, hooks: {}, hooksDisabled: false }; } 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) { + for (const [event, scriptName] of MANAGED_EVENTS) { const handlers = (next[event] ?? []).filter((handler) => { - const managedId = managedAgentId(handler, scriptName); - return !managedId || (agentId !== undefined && !sameAgentId(managedId, agentId)); + const owned = managedEntry(handler, scriptName); + return !owned || (agentId !== undefined && !sameAgentId(owned.agentId, agentId)); }); if (handlers.length > 0) next[event] = handlers; else delete next[event]; @@ -227,26 +256,33 @@ export function renderDocument( 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'; + 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] : []; - })); +function managedEntries(handlers: JsonObject[], scriptName: string): Map { + const entries = new Map(); + for (const handler of handlers) { + const entry = managedEntry(handler, scriptName); + if (entry) entries.set(agentKey(entry.agentId), entry); + } + return entries; } 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), - })); + const guards = managedEntries(hooks.preToolUse ?? [], GUARD_SCRIPT); + const successes = managedEntries(hooks.postToolUse ?? [], AUDIT_SCRIPT); + const failures = managedEntries(hooks.postToolUseFailure ?? [], AUDIT_SCRIPT); + const contracts: RuntimeContract[] = []; + for (const [key, guard] of guards) { + const success = successes.get(key); + const failure = failures.get(key); + if (!success || !failure || !samePath(success.scriptPath, failure.scriptPath)) continue; + contracts.push({ + agentId: guard.agentId, + guardPath: guard.scriptPath, + auditPath: success.scriptPath, + }); + } + return contracts; } diff --git a/sdks/node/src/plugins/copilot-installation.ts b/sdks/node/src/plugins/copilot-installation.ts new file mode 100644 index 0000000..cba7995 --- /dev/null +++ b/sdks/node/src/plugins/copilot-installation.ts @@ -0,0 +1,145 @@ +import path from 'node:path'; +import type { InstallConfig } from './base.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, + type CopilotSources, + type RenderedDocument, + samePath, +} from './copilot-contract.js'; +import { requireHooksEnabled } from './copilot-io.js'; +import { + commitManagedInstallation, + preflightManagedInstallation, + prepareManagedInstallation, + type ManagedRuntimePaths, + type PreparedManagedInstallation, + type RenameFile, +} from './managed-installation.js'; +import { + commitManagedTransaction, + prepareManagedFileChange, + type PreparedManagedTransaction, +} from './managed-transaction.js'; + +const DISPLAY_NAME = 'GitHub Copilot CLI'; +const DIRECTORY_LABEL = 'GitHub Copilot hooks directory'; +const SOURCE_LABEL = 'GitHub Copilot hook source'; +const MAX_SOURCE_BYTES = 2 * 1024 * 1024; + +export type CopilotRuntimePaths = ManagedRuntimePaths; +export type PreparedCopilotInstallation = PreparedManagedInstallation; +export type { RenameFile }; + +function hookLocations(sources: CopilotSources) { + return [sources.user, ...(sources.legacy ? [sources.legacy] : [])].map((document) => ({ + directoryLabel: DIRECTORY_LABEL, + filePath: document.filePath, + })); +} + +export async function preflightCopilotInstallation( + config: InstallConfig, + sources: CopilotSources, +): Promise { + requireHooksEnabled(sources); + return preflightManagedInstallation({ + agentKey: AGENT_KEY, + hookLocations: hookLocations(sources), + config, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +function installationDocuments( + sources: CopilotSources, + rendered: readonly RenderedDocument[], +): readonly RenderedDocument[] { + return rendered.filter((item) => item.changed + || samePath(item.document.filePath, sources.user.filePath)); +} + +export async function prepareCopilotInstallation( + config: InstallConfig, + sources: CopilotSources, + rendered: readonly RenderedDocument[], +): Promise { + await preflightCopilotInstallation(config, sources); + const documents = installationDocuments(sources, rendered); + const prepared = await prepareManagedInstallation({ + agentKey: AGENT_KEY, + displayName: DISPLAY_NAME, + hookSources: documents.map((item) => ({ + directoryLabel: DIRECTORY_LABEL, + label: SOURCE_LABEL, + filePath: item.document.filePath, + expectedSource: item.document.raw, + expectedSnapshot: item.document.snapshot, + source: item.changed ? item.next : item.document.raw, + })), + config, + auditOptions: { nativePayload: true }, + }, GUARD_SCRIPT, AUDIT_SCRIPT); + const legacyPrecondition = sources.legacy + && !documents.some((item) => samePath(item.document.filePath, sources.legacy!.filePath)) + ? [{ + filePath: sources.legacy.filePath, + label: 'GitHub Copilot legacy project hooks', + maximumBytes: MAX_SOURCE_BYTES, + original: sources.legacy.snapshot, + }] + : []; + return { + ...prepared, + transaction: { + ...prepared.transaction, + preconditions: [ + ...sources.settingsPreconditions.map((condition) => ({ + filePath: condition.filePath, + label: condition.label, + maximumBytes: MAX_SOURCE_BYTES, + original: condition.snapshot, + })), + ...legacyPrecondition, + ], + }, + }; +} + +export async function commitCopilotInstallation( + prepared: PreparedCopilotInstallation, + renameFile?: RenameFile, +): Promise { + await commitManagedInstallation(prepared, renameFile); +} + +export async function prepareCopilotUninstall( + rendered: readonly RenderedDocument[], +): Promise { + const changed = rendered.filter((item) => item.changed); + const changes = await Promise.all(changed.map((item) => prepareManagedFileChange({ + filePath: item.document.filePath, + label: SOURCE_LABEL, + next: item.next, + mode: 0o600, + expectedSource: item.document.raw, + expectedSnapshot: item.document.snapshot, + verifyExpectedSource: true, + }))); + return { + displayName: DISPLAY_NAME, + operation: 'uninstall', + directories: changed.map((item) => ({ + path: path.dirname(item.document.filePath), + label: DIRECTORY_LABEL, + })), + changes: changes.filter((change) => change !== undefined), + }; +} + +export async function commitCopilotUninstall( + prepared: PreparedManagedTransaction, + renameFile?: RenameFile, +): Promise { + await commitManagedTransaction(prepared, renameFile); +} diff --git a/sdks/node/src/plugins/copilot-io.ts b/sdks/node/src/plugins/copilot-io.ts index a64df9c..5df7262 100644 --- a/sdks/node/src/plugins/copilot-io.ts +++ b/sdks/node/src/plugins/copilot-io.ts @@ -1,281 +1,252 @@ -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, + AUDIT_SCRIPT, CONFIG_FILE, + GUARD_SCRIPT, type CopilotDocument, type CopilotSources, - type JsonObject, - type RenderedDocument, type RuntimeContract, createDocument, - isObject, parseDocument, + sameAgentId, + samePath, } from './copilot-contract.js'; +import { generateGuardScript } from './guard-template.js'; +import { generateHookScript } from './hook-template.js'; +import { + inspectPhysicalDirectory, + readPhysicalFile, + type FileSnapshot, +} from './managed-files.js'; +import { + parseStrictJsonObject, + parseStrictJsoncObject, + type JsonObject, +} from './strict-json.js'; -interface FileChange { +const MAX_SECRET_BYTES = 64 * 1024; +const MAX_CONFIG_BYTES = 512 * 1024; +const MAX_SETTINGS_BYTES = 2 * 1024 * 1024; + +export interface CopilotPaths { + readonly copilotHome: string; + readonly userHooksDirectory: string; + readonly userHookPath: string; + readonly legacyHookPath: string; + readonly settingsLayers: readonly SettingsLayer[]; + readonly inspectedDirectories: readonly DirectoryLocation[]; +} + +interface DirectoryLocation { + readonly path: string; + readonly label: string; +} + +interface SettingsLayer { readonly filePath: string; readonly label: string; - readonly original?: string; - readonly next?: string; + readonly jsonc: boolean; } -interface StagedChange { - readonly change: FileChange; - readonly tempPath?: string; - readonly rollbackPath?: string; - committed: boolean; +interface ParsedSettingsLayer extends SettingsLayer { + readonly disableAllHooks?: boolean; + readonly snapshot?: FileSnapshot; } -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'); +export function resolveCopilotPaths(): CopilotPaths { + const override = process.env.COPILOT_HOME; + const copilotHome = override ? override : path.join(os.homedir(), '.copilot'); + const project = process.cwd(); + const github = path.join(project, '.github'); + const githubCopilot = path.join(github, 'copilot'); + const githubHooks = path.join(github, 'hooks'); + const claude = path.join(project, '.claude'); return { - user: path.join(copilotHome, 'hooks', CONFIG_FILE), - legacy: path.join(process.cwd(), '.github', 'hooks', 'hooks.json'), + copilotHome, + userHooksDirectory: path.join(copilotHome, 'hooks'), + userHookPath: path.join(copilotHome, 'hooks', CONFIG_FILE), + legacyHookPath: path.join(githubHooks, 'hooks.json'), + settingsLayers: [ + { filePath: path.join(copilotHome, 'config.json'), label: 'legacy Copilot user config', jsonc: false }, + { filePath: path.join(copilotHome, 'settings.json'), label: 'Copilot user settings', jsonc: true }, + { filePath: path.join(claude, 'settings.json'), label: 'Claude repository settings', jsonc: true }, + { filePath: path.join(claude, 'settings.local.json'), label: 'Claude local settings', jsonc: true }, + { filePath: path.join(githubCopilot, 'settings.json'), label: 'Copilot repository settings', jsonc: true }, + { filePath: path.join(githubCopilot, 'settings.local.json'), label: 'Copilot local settings', jsonc: true }, + ], + inspectedDirectories: [ + { path: project, label: 'Copilot working directory' }, + { path: copilotHome, label: 'COPILOT_HOME' }, + { path: path.join(copilotHome, 'hooks'), label: 'Copilot user hooks directory' }, + { path: github, label: 'GitHub configuration directory' }, + { path: githubHooks, label: 'GitHub repository hooks directory' }, + { path: githubCopilot, label: 'Copilot repository settings directory' }, + { path: claude, label: 'Claude repository settings directory' }, + ], }; } -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 inspectDirectories(locations: readonly DirectoryLocation[]): Promise { + for (const location of locations) { + await inspectPhysicalDirectory(location.path, location.label); } } -async function readDocument(filePath: string, label: string): Promise { - const raw = await readOptional(filePath, label); - return raw === undefined ? undefined : parseDocument(filePath, raw, label); +async function readHookDocument( + filePath: string, + label: string, +): Promise { + const snapshot = await readPhysicalFile(filePath, label, MAX_SETTINGS_BYTES); + return snapshot ? parseDocument(filePath, snapshot, label) : undefined; +} + +function parseSettings(raw: string, layer: SettingsLayer): JsonObject { + if (raw.trim().length === 0) return {}; + const label = `${layer.label} at ${layer.filePath}`; + return layer.jsonc + ? parseStrictJsoncObject(raw, label) + : parseStrictJsonObject(raw, label); +} + +async function readSettingsLayer(layer: SettingsLayer): Promise { + const snapshot = await readPhysicalFile(layer.filePath, layer.label, MAX_SETTINGS_BYTES); + if (!snapshot) return layer; + const root = parseSettings(snapshot.contents, layer); + if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') { + throw new Error( + `${layer.label} at ${layer.filePath} field "disableAllHooks" must be a boolean`, + ); + } + return { + ...layer, + disableAllHooks: root.disableAllHooks as boolean | undefined, + snapshot, + }; +} + +function effectiveDisabledSource(layers: readonly ParsedSettingsLayer[]): string | undefined { + let disabledBy: string | undefined; + for (const layer of layers) { + if (layer.disableAllHooks === true) disabledBy = `${layer.label} at ${layer.filePath}`; + else if (layer.disableAllHooks === false) disabledBy = undefined; + } + return disabledBy; } export async function readSources(): Promise { - const configPaths = paths(); + const paths = resolveCopilotPaths(); + await inspectDirectories(paths.inspectedDirectories); const [user, legacy] = await Promise.all([ - readDocument(configPaths.user, 'GitHub Copilot user hooks'), - readDocument(configPaths.legacy, 'GitHub Copilot legacy project hooks'), + readHookDocument(paths.userHookPath, 'GitHub Copilot user hooks'), + readHookDocument(paths.legacyHookPath, 'GitHub Copilot legacy project hooks'), ]); + const layers = await Promise.all(paths.settingsLayers.map(readSettingsLayer)); + const userDocument = user ?? createDocument(paths.userHookPath); + const disabledBy = userDocument.hooksDisabled + ? `GitHub Copilot user hooks at ${paths.userHookPath}` + : effectiveDisabledSource(layers); return { - user: user ?? createDocument(configPaths.user), + user: userDocument, legacy, + disabledBy, + settingsPreconditions: layers.map((layer) => ({ + filePath: layer.filePath, + label: layer.label, + snapshot: layer.snapshot, + })), }; } -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; +export function requireHooksEnabled(sources: CopilotSources): void { + if (sources.disabledBy) { + throw new Error( + `GitHub Copilot hooks are disabled by ${sources.disabledBy}; set disableAllHooks to false before installation`, + ); } } -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] }); +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}`); } -} - -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; } +function validateRuntimeConfig( + config: JsonObject, + contract: RuntimeContract, + configPath: 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 !== AGENT_KEY) { + throw new Error(`Elydora runtime identity does not match Copilot 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: RuntimeContract): boolean { + const agentDirectory = path.dirname(contract.guardPath); + return samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora')) + && samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT)) + && samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT)); +} + 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 (!validContractPaths(contract)) continue; + const runtimeRoot = path.join(os.homedir(), '.elydora'); + const agentDirectory = path.dirname(contract.guardPath); + if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) continue; + if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) continue; + 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 (files.every(Boolean)) return true; + if (!config || !key || !guard || !audit) continue; + const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`); + validateRuntimeConfig(parsed, contract, configPath); + validatePrivateKey(key.contents, keyPath); + if (guard.contents === generateGuardScript(AGENT_KEY, contract.agentId) + && audit.contents === generateHookScript( + AGENT_KEY, + contract.agentId, + { nativePayload: true }, + )) return true; } return false; } diff --git a/sdks/node/src/plugins/copilot-schema.ts b/sdks/node/src/plugins/copilot-schema.ts new file mode 100644 index 0000000..073f5a2 --- /dev/null +++ b/sdks/node/src/plugins/copilot-schema.ts @@ -0,0 +1,162 @@ +import { isObject, type JsonObject } from './strict-json.js'; + +export type CopilotHooks = Record; + +const SUPPORTED_EVENTS = new Set([ + 'agentStop', + 'Stop', + 'errorOccurred', + 'ErrorOccurred', + 'notification', + 'Notification', + 'permissionRequest', + 'PermissionRequest', + 'postToolUse', + 'PostToolUse', + 'postToolUseFailure', + 'PostToolUseFailure', + 'preCompact', + 'PreCompact', + 'preToolUse', + 'PreToolUse', + 'sessionEnd', + 'SessionEnd', + 'sessionStart', + 'SessionStart', + 'subagentStart', + 'SubagentStart', + 'subagentStop', + 'SubagentStop', + 'userPromptSubmitted', + 'UserPromptSubmit', + 'userPromptTransformed', +]); + +function fieldLabel(label: string, field: string): string { + return `${label} field "${field}"`; +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function validateOptionalString(value: unknown, label: string): void { + if (value !== undefined && typeof value !== 'string') { + throw new Error(`${label} must be a string`); + } +} + +function validateTimeout(handler: JsonObject, label: string): void { + for (const field of ['timeout', 'timeoutSec']) { + const value = handler[field]; + if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)) { + throw new Error(`${fieldLabel(label, field)} must be a positive number`); + } + } +} + +function validateMatcher(handler: JsonObject, event: string, label: string): void { + if (handler.matcher === undefined) return; + const matcher = requireString(handler.matcher, fieldLabel(label, 'matcher')); + if ((event === 'PreToolUse' || event === 'PermissionRequest') + && (matcher === '*' || matcher === '**')) return; + try { + new RegExp(`^(?:${matcher})$`); + } catch (error) { + throw new Error(`${fieldLabel(label, 'matcher')} is not a valid regular expression`, { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } +} + +function validateStringMap(value: unknown, label: string): void { + if (!isObject(value)) throw new Error(`${label} must be an object`); + for (const [key, item] of Object.entries(value)) { + requireString(item, `${label}.${key}`); + } +} + +function validateCommand(handler: JsonObject, event: string, label: string): void { + const commands = ['bash', 'powershell', 'command'] + .map((field) => handler[field]) + .filter((value) => value !== undefined); + if (commands.length === 0) { + throw new Error(`${label} must define bash, powershell, or command`); + } + for (const field of ['bash', 'powershell', 'command', 'cwd']) { + validateOptionalString(handler[field], fieldLabel(label, field)); + } + if (handler.env !== undefined) validateStringMap(handler.env, fieldLabel(label, 'env')); + validateTimeout(handler, label); + validateMatcher(handler, event, label); +} + +function isLoopback(hostname: string): boolean { + return hostname === 'localhost' + || hostname === '::1' + || hostname.startsWith('127.'); +} + +function validateHttp(handler: JsonObject, event: string, label: string): void { + const rawUrl = requireString(handler.url, fieldLabel(label, 'url')); + let url: URL; + try { + url = new URL(rawUrl); + } catch (error) { + throw new Error(`${fieldLabel(label, 'url')} is invalid`, { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + const localhostAllowed = process.env.COPILOT_HOOK_ALLOW_LOCALHOST === '1' + && url.protocol === 'http:' + && isLoopback(url.hostname); + if (url.protocol !== 'https:' && !localhostAllowed) { + throw new Error(`${fieldLabel(label, 'url')} must use HTTPS`); + } + if (handler.headers !== undefined) { + validateStringMap(handler.headers, fieldLabel(label, 'headers')); + } + if (handler.allowedEnvVars !== undefined) { + if (!Array.isArray(handler.allowedEnvVars) + || handler.allowedEnvVars.some((value) => typeof value !== 'string' || !value)) { + throw new Error(`${fieldLabel(label, 'allowedEnvVars')} must be an array of strings`); + } + } + validateTimeout(handler, label); + validateMatcher(handler, event, label); +} + +function validatePrompt(handler: JsonObject, event: string, label: string): void { + if (event !== 'sessionStart' && event !== 'SessionStart') { + throw new Error(`${label} prompt hooks are supported only for sessionStart`); + } + requireString(handler.prompt, fieldLabel(label, 'prompt')); +} + +function validateHandler(handler: JsonObject, event: string, label: string): void { + const type = handler.type ?? 'command'; + if (type === 'command') validateCommand(handler, event, label); + else if (type === 'http') validateHttp(handler, event, label); + else if (type === 'prompt') validatePrompt(handler, event, label); + else throw new Error(`${fieldLabel(label, 'type')} is unsupported`); +} + +export 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 (!SUPPORTED_EVENTS.has(event)) throw new Error(`${label} hook event "${event}" is unsupported`); + if (!Array.isArray(handlers)) throw new Error(`${label} field "hooks.${event}" must be an array`); + hooks[event] = handlers.map((handler, index) => { + const itemLabel = `${label} handler hooks.${event}[${index}]`; + if (!isObject(handler)) throw new Error(`${itemLabel} must be an object`); + validateHandler(handler, event, itemLabel); + return handler; + }); + } + return hooks; +} diff --git a/sdks/node/src/plugins/copilot.ts b/sdks/node/src/plugins/copilot.ts index d027e18..06b8e0c 100644 --- a/sdks/node/src/plugins/copilot.ts +++ b/sdks/node/src/plugins/copilot.ts @@ -1,81 +1,100 @@ import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; import { AGENT_KEY, - AUDIT_SCRIPT, - GUARD_SCRIPT, buildHandler, removeManagedHooks, renderDocument, runtimeContracts, + type CopilotSources, + type RenderedDocument, } from './copilot-contract.js'; import { - readSources, - requireRuntime, - runtimeFilesExist, - writeDocuments, -} from './copilot-io.js'; + commitCopilotInstallation, + commitCopilotUninstall, + preflightCopilotInstallation, + prepareCopilotInstallation, + prepareCopilotUninstall, +} from './copilot-installation.js'; +import { readSources, runtimeFilesExist } from './copilot-io.js'; import { SUPPORTED_AGENTS } from './registry.js'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; -function mergedContracts( - userHooks: Parameters[0], - legacyHooks?: Parameters[0], -): ReturnType { - const contracts = [ - ...runtimeContracts(userHooks), - ...runtimeContracts(legacyHooks ?? {}), +function renderInstallation( + sources: CopilotSources, + guardPath: string, + auditPath: string, +): RenderedDocument[] { + const userHooks = removeManagedHooks(sources.user.hooks); + userHooks.preToolUse = [...(userHooks.preToolUse ?? []), buildHandler(guardPath)]; + userHooks.postToolUse = [...(userHooks.postToolUse ?? []), buildHandler(auditPath)]; + userHooks.postToolUseFailure = [ + ...(userHooks.postToolUseFailure ?? []), + buildHandler(auditPath), ]; - const unique = new Map(contracts.map((contract) => [contract.agentId, contract])); + const rendered = [renderDocument(sources.user, userHooks)]; + if (sources.legacy) { + rendered.push(renderDocument( + sources.legacy, + removeManagedHooks(sources.legacy.hooks), + )); + } + return rendered; +} + +function renderUninstall(sources: CopilotSources, agentId?: string): RenderedDocument[] { + const rendered = [ + renderDocument(sources.user, removeManagedHooks(sources.user.hooks, agentId)), + ]; + if (sources.legacy) { + rendered.push(renderDocument( + sources.legacy, + removeManagedHooks(sources.legacy.hooks, agentId), + )); + } + return rendered; +} + +function mergedContracts(sources: CopilotSources) { + const contracts = [ + ...runtimeContracts(sources.user.hooks), + ...runtimeContracts(sources.legacy?.hooks ?? {}), + ]; + const unique = new Map(contracts.map((contract) => [ + process.platform === 'win32' ? contract.agentId.toLowerCase() : contract.agentId, + contract, + ])); return [...unique.values()]; } export const copilotPlugin: AgentPlugin = { - async install(config: InstallConfig): Promise { - if (!config.agentId) throw new Error('agentId is required'); + managesRuntime: true, + + async preflightInstall(config: InstallConfig): Promise { const sources = await readSources(); - await requireRuntime(config.guardScriptPath, 'Elydora guard runtime'); - await requireRuntime(config.hookScriptPath, 'Elydora audit runtime'); + await preflightCopilotInstallation(config, sources); + }, - const userHooks = removeManagedHooks(sources.user.hooks); - userHooks.preToolUse = [ - ...(userHooks.preToolUse ?? []), - buildHandler(config.guardScriptPath), - ]; - userHooks.postToolUse = [ - ...(userHooks.postToolUse ?? []), - buildHandler(config.hookScriptPath), - ]; - - const rendered = [renderDocument(sources.user, userHooks)]; - if (sources.legacy) { - rendered.push(renderDocument( - sources.legacy, - removeManagedHooks(sources.legacy.hooks), - )); - } - await writeDocuments(rendered); + async install(config: InstallConfig): Promise { + const sources = await readSources(); + const paths = await preflightCopilotInstallation(config, sources); + const rendered = renderInstallation(sources, paths.guardPath, paths.auditPath); + const prepared = await prepareCopilotInstallation(config, sources, rendered); + await commitCopilotInstallation(prepared); console.log(` GitHub Copilot CLI hooks: ${sources.user.filePath}`); + console.log(' GitHub Copilot CLI: restart active sessions to load the updated hooks.'); }, async uninstall(agentId?: string): Promise { 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), - )); - } - await writeDocuments(rendered); + const prepared = await prepareCopilotUninstall(renderUninstall(sources, agentId)); + await commitCopilotUninstall(prepared); }, async status(): Promise { const sources = await readSources(); - const contracts = mergedContracts(sources.user.hooks, sources.legacy?.hooks); - const hookConfigured = contracts.length > 0; + const contracts = mergedContracts(sources); + const hookConfigured = sources.disabledBy === undefined && contracts.length > 0; const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts); return { installed: hookConfigured && hookScriptExists, diff --git a/sdks/node/src/plugins/managed-installation.ts b/sdks/node/src/plugins/managed-installation.ts index f4e9525..c858c17 100644 --- a/sdks/node/src/plugins/managed-installation.ts +++ b/sdks/node/src/plugins/managed-installation.ts @@ -4,7 +4,11 @@ import { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime import type { InstallConfig } from './base.js'; import { generateGuardScript, type GuardScriptOptions } from './guard-template.js'; import { generateHookScript, type HookScriptOptions } from './hook-template.js'; -import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; +import { + inspectPhysicalDirectory, + readPhysicalFile, + type FileSnapshot, +} from './managed-files.js'; import { commitManagedTransaction, prepareManagedFileChange, @@ -35,7 +39,8 @@ export interface ManagedHookLocation { export interface ManagedHookSource extends ManagedHookLocation { readonly label: string; readonly expectedSource?: string; - readonly source: string; + readonly expectedSnapshot?: FileSnapshot; + readonly source?: string; } export interface ManagedRuntimeFile { @@ -292,6 +297,7 @@ export async function prepareManagedInstallation( next: source.source, mode: 0o600, expectedSource: source.expectedSource, + expectedSnapshot: source.expectedSnapshot, verifyExpectedSource: true, })), ]); diff --git a/sdks/node/src/plugins/managed-transaction.ts b/sdks/node/src/plugins/managed-transaction.ts index cb3290c..f395445 100644 --- a/sdks/node/src/plugins/managed-transaction.ts +++ b/sdks/node/src/plugins/managed-transaction.ts @@ -17,6 +17,7 @@ export interface ManagedChangeSpec { readonly mode: number; readonly maximumBytes?: number; readonly expectedSource?: string; + readonly expectedSnapshot?: FileSnapshot; readonly verifyExpectedSource?: boolean; } @@ -34,11 +35,19 @@ export interface ManagedDirectory { readonly label: string; } +export interface ManagedFilePrecondition { + readonly filePath: string; + readonly label: string; + readonly maximumBytes: number; + readonly original?: FileSnapshot; +} + export interface PreparedManagedTransaction { readonly displayName: string; readonly operation?: 'install' | 'uninstall'; readonly directories: readonly ManagedDirectory[]; readonly changes: readonly ManagedFileChange[]; + readonly preconditions?: readonly ManagedFilePrecondition[]; } interface StagedChange { @@ -88,6 +97,11 @@ export async function prepareManagedFileChange( if (spec.verifyExpectedSource && original?.contents !== spec.expectedSource) { throw new Error(`${spec.label} changed before installation: ${spec.filePath}`); } + if (spec.expectedSnapshot && (!original + || original.device !== spec.expectedSnapshot.device + || original.inode !== spec.expectedSnapshot.inode)) { + throw new Error(`${spec.label} changed before installation: ${spec.filePath}`); + } if (!original && spec.next === undefined) return undefined; return { filePath: spec.filePath, @@ -112,6 +126,28 @@ async function assertUnchanged(change: ManagedFileChange, operationName: string) } } +async function assertPreconditions( + transaction: PreparedManagedTransaction, + operationName: string, +): Promise { + for (const condition of transaction.preconditions ?? []) { + const current = await readPhysicalFile( + condition.filePath, + condition.label, + condition.maximumBytes, + ); + if ((!current && condition.original) + || (current && !condition.original) + || (current && condition.original && ( + current.contents !== condition.original.contents + || current.device !== condition.original.device + || current.inode !== condition.original.inode + ))) { + throw new Error(`${condition.label} changed during ${operationName}: ${condition.filePath}`); + } + } +} + async function writeExclusive( filePath: string, contents: string, @@ -336,13 +372,20 @@ export async function commitManagedTransaction( } targets.add(key); } + await assertPreconditions(transaction, operationName); for (const directory of transactionDirectories(transaction)) { await ensureManagedDirectory(directory.path, directory.label); } + await assertPreconditions(transaction, operationName); const staged: StagedChange[] = []; try { for (const change of transaction.changes) staged.push(await stage(change, operationName)); - for (const item of staged) await commit(item, operationName, renameFile); + await assertPreconditions(transaction, operationName); + for (const item of staged) { + await assertPreconditions(transaction, operationName); + await commit(item, operationName, renameFile); + } + await assertPreconditions(transaction, operationName); } catch (error) { const failures = [asError(error)]; for (const item of [...staged].reverse()) { diff --git a/sdks/node/src/plugins/strict-json.ts b/sdks/node/src/plugins/strict-json.ts index 286025d..238f172 100644 --- a/sdks/node/src/plugins/strict-json.ts +++ b/sdks/node/src/plugins/strict-json.ts @@ -8,6 +8,11 @@ import { export type JsonObject = Record; +interface JsonObjectParseOptions { + readonly allowTrailingComma: boolean; + readonly disallowComments: boolean; +} + export function isObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -36,12 +41,13 @@ function rejectDuplicateKeys( } } -export function parseStrictJsonObject(raw: string, label: string): JsonObject { +function parseJsonObject( + raw: string, + label: string, + options: JsonObjectParseOptions, +): JsonObject { const errors: ParseError[] = []; - const value: unknown = parse(raw, errors, { - allowTrailingComma: false, - disallowComments: true, - }); + const value: unknown = parse(raw, errors, options); if (errors.length > 0) { const details = errors .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`) @@ -49,9 +55,20 @@ export function parseStrictJsonObject(raw: string, label: string): JsonObject { throw new Error(`Failed to parse ${label}: ${details}`); } if (!isObject(value)) throw new Error(`${label} must contain a JSON object`); - rejectDuplicateKeys(parseTree(raw, [], { - allowTrailingComma: false, - disallowComments: true, - }), label); + rejectDuplicateKeys(parseTree(raw, [], options), label); return value; } + +export function parseStrictJsonObject(raw: string, label: string): JsonObject { + return parseJsonObject(raw, label, { + allowTrailingComma: false, + disallowComments: true, + }); +} + +export function parseStrictJsoncObject(raw: string, label: string): JsonObject { + return parseJsonObject(raw, label, { + allowTrailingComma: true, + disallowComments: false, + }); +} diff --git a/sdks/node/test-support/copilot-test-helpers.mjs b/sdks/node/test-support/copilot-test-helpers.mjs new file mode 100644 index 0000000..35ff8db --- /dev/null +++ b/sdks/node/test-support/copilot-test-helpers.mjs @@ -0,0 +1,227 @@ +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, 11).toString('base64url'); +export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/copilot.js')).href; +export const contractModuleUrl = pathToFileURL( + path.resolve('dist/plugins/copilot-contract.js'), +).href; +export const installationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/copilot-installation.js'), +).href; +export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/copilot-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, copilotHome = fixture.copilotHome) { + return { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + COPILOT_HOME: copilotHome === null ? undefined : copilotHome, + }; +} + +export function installConfig(fixture, overrides = {}) { + return { + agentName: 'copilot', + 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, copilotHome = fixture.copilotHome) { + 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], + { + ...environment(fixture, copilotHome), + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }, + fixture.projectDir, + ); +} + +export function runHook(handler, fixture, input) { + const command = process.platform === 'win32' + ? ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', handler.powershell]] + : ['/bin/sh', ['-c', handler.bash]]; + return runProcess(command[0], command[1], environment(fixture), fixture.projectDir, input); +} + +export async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const source = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; + await writeFile(filePath, source, { mode: 0o600 }); +} + +async function writeOptional(filePath, value) { + if (value !== undefined) await writeJson(filePath, value); +} + +export async function createFixture({ + agentId = 'agent-1', + baseUrl = 'http://127.0.0.1:9', + userConfig, + legacyConfig, + userSettings, + legacyUserConfig, + claudeSettings, + claudeLocalSettings, + repositorySettings, + localSettings, +} = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-copilot-')); + const homeDir = path.join(rootDir, "home with spaces and 'quote %COPILOT%"); + const projectDir = path.join(rootDir, 'project with spaces'); + const copilotHome = path.join(rootDir, "custom Copilot 'home"); + const hooksDir = path.join(copilotHome, 'hooks'); + const configPath = path.join(hooksDir, 'elydora-audit.json'); + const legacyPath = path.join(projectDir, '.github', 'hooks', 'hooks.json'); + const agentDir = path.join(homeDir, '.elydora', agentId); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + await mkdir(projectDir, { recursive: true }); + await Promise.all([ + writeOptional(configPath, userConfig), + writeOptional(legacyPath, legacyConfig), + writeOptional(path.join(copilotHome, 'settings.json'), userSettings), + writeOptional(path.join(copilotHome, 'config.json'), legacyUserConfig), + writeOptional(path.join(projectDir, '.claude', 'settings.json'), claudeSettings), + writeOptional(path.join(projectDir, '.claude', 'settings.local.json'), claudeLocalSettings), + writeOptional(path.join(projectDir, '.github', 'copilot', 'settings.json'), repositorySettings), + writeOptional(path.join(projectDir, '.github', 'copilot', 'settings.local.json'), localSettings), + ]); + return { + agentDir, + agentId, + baseUrl, + configPath, + copilotHome, + guardScriptPath, + homeDir, + hookScriptPath, + hooksDir, + legacyPath, + projectDir, + rootDir, + install(overrides = {}, copilotHomeOverride = this.copilotHome) { + return runPlugin(this, 'install', installConfig(this, overrides), copilotHomeOverride); + }, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; +} + +export function managedHandler(config, event, scriptName) { + return config.hooks?.[event]?.find( + (handler) => handler.bash?.includes(scriptName) || handler.powershell?.includes(scriptName), + ); +} + +export function assertNativeHandler(handler) { + if (!handler) throw new Error('Managed handler is missing'); + const keys = ['bash', 'powershell', 'timeoutSec', 'type']; + if (JSON.stringify(Object.keys(handler).sort()) !== JSON.stringify(keys)) { + throw new Error(`Unexpected handler fields: ${Object.keys(handler).join(', ')}`); + } + if (handler.type !== 'command' || handler.timeoutSec !== 10) { + throw new Error(`Unexpected handler contract: ${JSON.stringify(handler)}`); + } +} + +export 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, + }, + }; +} + +export async function readJson(filePath) { + return JSON.parse(await readFile(filePath, 'utf-8')); +} + +export async function startApiServer() { + const requests = []; + 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 }); + response.writeHead(request.method === 'POST' ? 201 : 200, { + 'Content-Type': 'application/json', + }); + response.end(request.method === 'POST' + ? '{"operation":{"accepted":true}}' + : '{"agent":{"status":"active"}}'); + }); + 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, + close: () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }), + }; +} diff --git a/sdks/node/test/copilot-official.test.mjs b/sdks/node/test/copilot-official.test.mjs new file mode 100644 index 0000000..aec1bda --- /dev/null +++ b/sdks/node/test/copilot-official.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + environment, + runNode, +} from '../test-support/copilot-test-helpers.mjs'; + +const copilotEntry = process.env.ELYDORA_COPILOT_ENTRY; +const copilotRuntimeEntry = process.env.ELYDORA_COPILOT_RUNTIME_ENTRY; + +test('official GitHub Copilot CLI 1.0.71 loads all three managed hooks', { + skip: copilotEntry && copilotRuntimeEntry + ? false + : 'set ELYDORA_COPILOT_ENTRY and ELYDORA_COPILOT_RUNTIME_ENTRY to official package files', +}, async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const version = await runNode( + [copilotEntry, '--version'], + environment(fixture), + fixture.projectDir, + ); + assert.equal(version.code, 0, version.stderr); + assert.match(version.stdout, /^GitHub Copilot CLI 1\.0\.71\.$/m); + + const source = ` + import { createRequire } from 'node:module'; + const require = createRequire(import.meta.url); + const runtime = require(process.env.ELYDORA_COPILOT_RUNTIME_ENTRY); + const session = await runtime.hookSessionCreate({ + cwd: process.env.ELYDORA_PROJECT, + repoRoot: process.env.ELYDORA_PROJECT, + sessionId: 'elydora-official-test', + settingsJson: '{}', + userHooksDir: process.env.ELYDORA_HOOKS, + allowLocalhost: false, + allowHttpAuthHooks: false, + discoverPolicies: false, + }); + try { + const snapshot = JSON.parse(await runtime.hookSessionSnapshot(session.handle)); + console.log(JSON.stringify({ load: session.load, snapshot })); + } finally { + runtime.hookSessionDispose(session.handle); + } + `; + const load = await runNode( + ['--input-type=module', '--eval', source], + { + ...environment(fixture), + ELYDORA_COPILOT_RUNTIME_ENTRY: copilotRuntimeEntry, + ELYDORA_HOOKS: fixture.hooksDir, + ELYDORA_PROJECT: fixture.projectDir, + }, + fixture.projectDir, + ); + assert.equal(load.code, 0, load.stderr); + const result = JSON.parse(load.stdout); + assert.equal(result.load.hookCount, 3); + assert.deepEqual(result.load.errors, []); + assert.deepEqual(result.load.warnings, []); + assert.deepEqual( + result.snapshot.hooks.map((hook) => hook.eventName).sort(), + ['postToolUse', 'postToolUseFailure', 'preToolUse'], + ); + for (const hook of result.snapshot.hooks) { + assert.match(hook.source, /hooks[\\/]elydora-audit\.json$/); + const spec = JSON.parse(hook.specJson); + assert.equal(spec.config.type, 'command'); + assert.equal(spec.config.timeoutSec, 10); + assert.match(spec.config.bash, /node(?:\.exe)?/i); + assert.match(spec.config.powershell, /^& /); + assert.equal(path.basename(spec.source), 'elydora-audit.json'); + } + } finally { + await fixture.close(); + } +}); diff --git a/sdks/node/test/copilot-plugin.test.mjs b/sdks/node/test/copilot-plugin.test.mjs index 25c1121..8283d0e 100644 --- a/sdks/node/test/copilot-plugin.test.mjs +++ b/sdks/node/test/copilot-plugin.test.mjs @@ -1,161 +1,49 @@ 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 { + lstat, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; -import { pathToFileURL } from 'node:url'; +import { + VALID_PRIVATE_KEY, + assertNativeHandler, + cliPath, + createFixture, + environment, + legacyManagedConfig, + managedHandler, + readJson, + registryModuleUrl, + runHook, + runNode, + runPlugin, + startApiServer, + writeJson, +} from '../test-support/copilot-test-helpers.mjs'; -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); - }); +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); } -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', - })); +async function assertNoTransactionFiles(fixture) { + const names = []; + for (const directory of [fixture.agentDir, fixture.hooksDir, path.dirname(fixture.legacyPath)]) { + try { + names.push(...await readdir(directory)); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } } - 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 }); - }, - }; + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', ')); } -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 () => { +test('GitHub Copilot CLI is registered with its native user hook directory', async () => { const { SUPPORTED_AGENTS } = await import(registryModuleUrl); assert.deepEqual(SUPPORTED_AGENTS.get('copilot'), { name: 'GitHub Copilot CLI', @@ -164,44 +52,77 @@ test('GitHub Copilot CLI is registered with the native user hook file', async () }); }); -test('Copilot install preserves user hooks, migrates legacy entries, and is idempotent', async () => { +test('Copilot installs five managed files, preserves valid hooks, and migrates legacy entries', async () => { const fixture = await createFixture({ userConfig: { version: 1, disableAllHooks: false, + owner: 'user', hooks: { - sessionStart: [{ type: 'command', command: 'user-session-hook' }], - preToolUse: [{ type: 'command', command: 'user-pre-hook' }], + sessionStart: [{ type: 'prompt', prompt: '/compact' }], + preToolUse: [{ type: 'command', command: 'user-pre-hook', timeout: 3 }], + postToolUse: [{ type: 'command', bash: 'user-post-hook' }], + postToolUseFailure: [{ command: 'user-failure-hook', matcher: 'powershell' }], }, }, }); try { await writeJson(fixture.legacyPath, legacyManagedConfig(fixture, { - notification: [{ type: 'command', command: 'user-notification-hook' }], + notification: [{ + type: 'command', + command: 'user-notification-hook', + matcher: 'agent_idle|permission_prompt', + }], })); - 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); + const managedFiles = [ + fixture.guardScriptPath, + path.join(fixture.agentDir, 'config.json'), + path.join(fixture.agentDir, 'private.key'), + fixture.hookScriptPath, + fixture.configPath, + ]; + const snapshot = new Map(await Promise.all(managedFiles.map(async (filePath) => [ + filePath, + await readFile(filePath, 'utf-8'), + ]))); + const config = JSON.parse(snapshot.get(fixture.configPath)); + assert.equal(config.owner, 'user'); 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.deepEqual(config.hooks.sessionStart, [{ type: 'prompt', prompt: '/compact' }]); assert.equal(config.hooks.preToolUse.length, 2); - assert.equal(config.hooks.postToolUse.length, 1); + assert.equal(config.hooks.postToolUse.length, 2); + assert.equal(config.hooks.postToolUseFailure.length, 2); 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, { + assertNativeHandler(managedHandler(config, 'postToolUseFailure', 'hook.js')); + assert.deepEqual(await readJson(path.join(fixture.agentDir, 'config.json')), { + org_id: 'org-1', + agent_id: fixture.agentId, + kid: 'kid-1', + base_url: fixture.baseUrl, + token: 'token-1', + agent_name: 'copilot', + }); + assert.equal(await readFile(path.join(fixture.agentDir, 'private.key'), 'utf-8'), VALID_PRIVATE_KEY); + assert.deepEqual(await readJson(fixture.legacyPath), { version: 1, hooks: { - notification: [{ type: 'command', command: 'user-notification-hook' }], + notification: [{ + type: 'command', + command: 'user-notification-hook', + matcher: 'agent_idle|permission_prompt', + }], }, }); + + const second = await fixture.install(); + assert.equal(second.code, 0, second.stderr); + for (const [filePath, source] of snapshot) { + assert.equal(await readFile(filePath, 'utf-8'), source, filePath); + } + await assertNoTransactionFiles(fixture); } finally { await fixture.close(); } @@ -213,105 +134,214 @@ test('Copilot migration removes a legacy file owned entirely by Elydora', async 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')); + await assertMissing(fixture.legacyPath); + assertNativeHandler(managedHandler(await readJson(fixture.configPath), '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))); - `, - }); +test('Copilot guard blocks frozen agents and audit records success and failure payloads', async () => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); try { - const install = await fixture.install(); - assert.equal(install.code, 0, install.stderr); - const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal((await fixture.install()).code, 0); + const config = await readJson(fixture.configPath); const guard = managedHandler(config, 'preToolUse', 'guard.js'); - const audit = managedHandler(config, 'postToolUse', 'hook.js'); - const prePayload = JSON.stringify({ + const successAudit = managedHandler(config, 'postToolUse', 'hook.js'); + const failureAudit = managedHandler(config, 'postToolUseFailure', 'hook.js'); + const prePayload = { sessionId: 'session-1', - timestamp: Date.now(), + timestamp: 1784486400000, cwd: fixture.projectDir, toolName: 'powershell', toolArgs: { command: 'Get-ChildItem' }, + }; + await writeJson(path.join(fixture.agentDir, 'status-cache.json'), { + status: 'frozen', + cached_at: Date.now(), }); - const guardResult = await runHook(guard, prePayload); - assert.equal(guardResult.code, 2, guardResult.stderr); - assert.match(guardResult.stderr, /Agent is frozen by Elydora/); + let result = await runHook(guard, fixture, JSON.stringify(prePayload)); + assert.equal(result.code, 2, result.stderr); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Agent "copilot" is frozen/); - const postPayload = JSON.stringify({ - sessionId: 'session-1', - timestamp: Date.now(), - cwd: fixture.projectDir, - toolName: 'powershell', - toolArgs: { command: 'Get-ChildItem' }, - toolResult: { output: 'ok' }, + await writeJson(path.join(fixture.agentDir, 'status-cache.json'), { + status: 'active', + cached_at: Date.now(), }); - const auditResult = await runHook(audit, postPayload); - assert.equal(auditResult.code, 0, auditResult.stderr); - assert.equal(await readFile(capturePath, 'utf-8'), postPayload); + result = await runHook(guard, fixture, JSON.stringify(prePayload)); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, ''); + + const successPayload = { + ...prePayload, + toolResult: { resultType: 'success', textResultForLlm: 'ok' }, + }; + result = await runHook(successAudit, fixture, JSON.stringify(successPayload)); + assert.equal(result.code, 0, result.stderr); + const failurePayload = { ...prePayload, error: 'command failed' }; + result = await runHook(failureAudit, fixture, JSON.stringify(failurePayload)); + assert.equal(result.code, 0, result.stderr); + + const operations = api.requests + .filter((request) => request.method === 'POST') + .map((request) => JSON.parse(request.raw)); + assert.equal(operations.length, 2); + assert.deepEqual(operations[0].payload, successPayload); + assert.deepEqual(operations[1].payload, failurePayload); + assert.deepEqual(operations[1].subject, { session_id: 'session-1' }); + assert.deepEqual(operations[1].action, { tool: 'powershell' }); } finally { + await api.close(); await fixture.close(); - await rm(capturePath, { force: true }); } }); -test('Copilot treats an empty home override as the official default', async () => { +test('Copilot uses the official default for an empty COPILOT_HOME', async () => { const fixture = await createFixture(); try { - fixture.copilotHome = ''; - const result = await fixture.install(); + 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); + assertNativeHandler(managedHandler(await readJson(defaultPath), '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 status requires the complete active contract and exact runtime sources', async (t) => { + const cases = [ + ['missing failure event', 'config', /"hookConfigured":false/], + ['tampered guard', 'guard', /"installed":false/], + ['malformed runtime config', 'runtime', /parse Elydora runtime config/i], + ['invalid private key', 'key', /private key is invalid/i], + ]; + for (const [label, target, expected] of cases) { + await t.test(label, async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const healthy = await runPlugin(fixture, 'status', null); + assert.deepEqual(JSON.parse(healthy.stdout), { + installed: true, + agentName: 'copilot', + displayName: 'GitHub Copilot CLI', + hookConfigured: true, + hookScriptExists: true, + configPath: fixture.configPath, + }); + if (target === 'config') { + const config = await readJson(fixture.configPath); + delete config.hooks.postToolUseFailure; + await writeJson(fixture.configPath, config); + } else if (target === 'guard') { + await writeFile(fixture.guardScriptPath, 'tampered\n'); + } else if (target === 'runtime') { + await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); + } else { + await writeFile(path.join(fixture.agentDir, 'private.key'), 'invalid'); + } + const status = await runPlugin(fixture, 'status', null); + assert.match(`${status.stdout}\n${status.stderr}`, expected); + } finally { + await fixture.close(); + } + }); } }); -test('Copilot uninstall removes exact ownership and preserves user entries', async () => { +test('Copilot resolves disableAllHooks through the official settings precedence', async (t) => { + await t.test('managed file flag blocks installation', async () => { + const fixture = await createFixture({ + userConfig: { version: 1, disableAllHooks: true, hooks: {} }, + localSettings: { disableAllHooks: false }, + }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /hooks are disabled/i); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } + }); + + await t.test('local repository settings re-enable user-disabled hooks', async () => { + const fixture = await createFixture({ + userSettings: '{\n // user pause\n "disableAllHooks": true,\n}\n', + localSettings: { disableAllHooks: false }, + }); + try { + const result = await fixture.install(); + assert.equal(result.code, 0, result.stderr); + } finally { + await fixture.close(); + } + }); + + await t.test('repository settings disable every non-policy source', async () => { + const fixture = await createFixture({ + userSettings: { disableAllHooks: false }, + repositorySettings: { disableAllHooks: true }, + }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /Copilot repository settings/); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } + }); + + await t.test('GitHub repository settings override Claude local settings', async () => { + const fixture = await createFixture({ + claudeLocalSettings: { disableAllHooks: true }, + repositorySettings: { disableAllHooks: false }, + }); + try { + const result = await fixture.install(); + assert.equal(result.code, 0, result.stderr); + } finally { + await fixture.close(); + } + }); +}); + +test('Copilot rejects malformed current schemas and preserves every source', async () => { + const cases = [ + '{ malformed', + '{"version":1,"version":1,"hooks":{}}', + { version: 2, hooks: {} }, + { version: 1, hooks: { futureEvent: [] } }, + { version: 1, hooks: { preToolUse: [{ type: 'unknown' }] } }, + { version: 1, hooks: { postToolUse: [{ type: 'http', url: 'http://example.com' }] } }, + { version: 1, hooks: { sessionEnd: [{ type: 'prompt', prompt: 'continue' }] } }, + ]; + for (const userConfig of cases) { + const fixture = await createFixture({ userConfig }); + try { + const before = await readFile(fixture.configPath, 'utf-8'); + const result = await fixture.install(); + assert.equal(result.code, 1, `${JSON.stringify(userConfig)}\n${result.stderr}`); + assert.equal(await readFile(fixture.configPath, 'utf-8'), before); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } + } +}); + +test('Copilot uninstall removes exact ownership and preserves adjacent handlers', async () => { const fixture = await createFixture({ - userConfig: { version: 1, hooks: { notification: [{ type: 'command', command: 'keep' }] } }, + userConfig: { version: 1, hooks: { notification: [{ command: 'keep' }] } }, }); try { assert.equal((await fixture.install()).code, 0); - const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const config = await readJson(fixture.configPath); config.hooks.preToolUse.push({ type: 'command', bash: `'${process.execPath}' '${path.join(fixture.homeDir, '.elydora', 'agent-10', 'guard.js')}'`, @@ -319,12 +349,15 @@ test('Copilot uninstall removes exact ownership and preserves user entries', asy timeoutSec: 10, }); await writeJson(fixture.configPath, config); + assert.equal((await runPlugin(fixture, 'uninstall', 'agent-10')).code, 0); + assert.equal((await readJson(fixture.configPath)).hooks.postToolUseFailure.length, 1); - const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + const result = await runPlugin(fixture, 'uninstall', fixture.agentId); 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' }]); + const remaining = await readJson(fixture.configPath); + assert.deepEqual(remaining.hooks.notification, [{ command: 'keep' }]); assert.equal(remaining.hooks.postToolUse, undefined); + assert.equal(remaining.hooks.postToolUseFailure, undefined); assert.equal(remaining.hooks.preToolUse.length, 1); assert.match(remaining.hooks.preToolUse[0].bash, /agent-10/); } finally { @@ -332,57 +365,61 @@ test('Copilot uninstall removes exact ownership and preserves user entries', asy } }); -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 rejects linked hook and runtime paths before writes', async (t) => { + for (const kind of ['home', 'hook', 'runtime']) { + await t.test(kind, async () => { + const fixture = await createFixture(); + try { + const target = path.join(fixture.rootDir, `${kind}-target`); + await mkdir(target, { recursive: true }); + if (kind === 'home') { + await rm(fixture.copilotHome, { recursive: true, force: true }); + await symlink(target, fixture.copilotHome, 'junction'); + } else if (kind === 'hook') { + await mkdir(fixture.hooksDir, { recursive: true }); + const targetFile = path.join(target, 'config.json'); + await writeJson(targetFile, { version: 1, hooks: {} }); + await symlink(targetFile, fixture.configPath, 'file'); + } else { + await mkdir(fixture.homeDir, { recursive: true }); + await symlink(target, path.join(fixture.homeDir, '.elydora'), 'junction'); + } + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /physical (directory|file)/i); + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } + throw error; + } 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 }); +test('Copilot CLI preflight blocks disabled hooks before runtime creation', async () => { + const fixture = await createFixture({ repositorySettings: { disableAllHooks: true } }); + const privateKeyFile = path.join(fixture.rootDir, 'private-key'); try { - const result = await fixture.install(); + await writeFile(privateKeyFile, VALID_PRIVATE_KEY, { mode: 0o600 }); + const result = await runNode([ + '--no-warnings', + cliPath, + 'install', + '--agent', 'copilot', + '--org_id', 'org-1', + '--agent_id', fixture.agentId, + '--kid', 'kid-1', + '--private_key_file', privateKeyFile, + '--base_url', fixture.baseUrl, + ], environment(fixture), fixture.projectDir); 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); + assert.match(result.stderr, /hooks are disabled/i); + await assertMissing(path.join(fixture.homeDir, '.elydora')); + await assertMissing(fixture.configPath); } finally { await fixture.close(); } diff --git a/sdks/node/test/copilot-transaction.test.mjs b/sdks/node/test/copilot-transaction.test.mjs new file mode 100644 index 0000000..084b7ab --- /dev/null +++ b/sdks/node/test/copilot-transaction.test.mjs @@ -0,0 +1,328 @@ +import assert from 'node:assert/strict'; +import { + lstat, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + contractModuleUrl, + createFixture, + environment, + installConfig, + installationModuleUrl, + ioModuleUrl, + legacyManagedConfig, + runNode, + writeJson, +} from '../test-support/copilot-test-helpers.mjs'; + +const managedFiles = ['guard.js', 'config.json', 'private.key', 'hook.js']; + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +async function snapshotInstallation(fixture, includeLegacy = false) { + const paths = [ + ...managedFiles.map((name) => path.join(fixture.agentDir, name)), + fixture.configPath, + ...(includeLegacy ? [fixture.legacyPath] : []), + ]; + return new Map(await Promise.all(paths.map(async (filePath) => [ + filePath, + await readFile(filePath, 'utf-8'), + ]))); +} + +async function assertSnapshot(snapshot) { + for (const [filePath, source] of snapshot) { + assert.equal(await readFile(filePath, 'utf-8'), source, filePath); + } +} + +async function assertNoTransactionFiles(fixture) { + const names = []; + for (const directory of [fixture.agentDir, fixture.hooksDir, path.dirname(fixture.legacyPath)]) { + 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(', ')); +} + +function transactionEnvironment(fixture, overrides = {}) { + return { + ...environment(fixture), + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, overrides)), + }; +} + +const readAndRenderSource = ` + import { + buildHandler, + removeManagedHooks, + renderDocument, + } from ${JSON.stringify(contractModuleUrl)}; + import { readSources } from ${JSON.stringify(ioModuleUrl)}; + import { + preflightCopilotInstallation, + prepareCopilotInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG); + const sources = await readSources(); +`; + +const prepareSource = `${readAndRenderSource} + const paths = await preflightCopilotInstallation(config, sources); + const hooks = removeManagedHooks(sources.user.hooks); + hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(paths.guardPath)]; + hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(paths.auditPath)]; + hooks.postToolUseFailure = [ + ...(hooks.postToolUseFailure ?? []), + buildHandler(paths.auditPath), + ]; + const rendered = [renderDocument(sources.user, hooks)]; + if (sources.legacy) { + rendered.push(renderDocument(sources.legacy, removeManagedHooks(sources.legacy.hooks))); + } + const prepared = await prepareCopilotInstallation(config, sources, rendered); +`; + +test('Copilot rolls back runtime, user hooks, and legacy migration after a late failure', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const installed = await readFile(fixture.configPath, 'utf-8'); + await writeJson(fixture.legacyPath, installed); + const before = await snapshotInstallation(fixture, true); + const source = `${prepareSource} + import { commitCopilotInstallation } from ${JSON.stringify(installationModuleUrl)}; + import { rename } from 'node:fs/promises'; + let calls = 0; + await commitCopilotInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 6) throw new Error('injected Copilot rename failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + transactionEnvironment(fixture, { orgId: 'org-updated', token: 'token-updated' }), + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected Copilot rename failure/i); + await assertSnapshot(before); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Copilot detects concurrent hook replacement before committing runtime files', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '{"version":1,"hooks":{"preToolUse":[{"command":"concurrent"}]}}\n'; + const source = `${prepareSource} + import { writeFile } from 'node:fs/promises'; + import { commitCopilotInstallation } from ${JSON.stringify(installationModuleUrl)}; + await writeFile(process.env.ELYDORA_CONCURRENT_PATH, process.env.ELYDORA_CONCURRENT); + await commitCopilotInstallation(prepared); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + ...transactionEnvironment(fixture, { orgId: 'org-updated' }), + ELYDORA_CONCURRENT_PATH: fixture.configPath, + ELYDORA_CONCURRENT: concurrent, + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed during GitHub Copilot CLI installation/i); + for (const [filePath, sourceBefore] of before) { + if (filePath !== fixture.configPath) { + assert.equal(await readFile(filePath, 'utf-8'), sourceBefore, filePath); + } + } + assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Copilot rolls back when effective settings change during installation', async () => { + const fixture = await createFixture({ repositorySettings: { disableAllHooks: false } }); + const settingsPath = path.join( + fixture.projectDir, + '.github', + 'copilot', + 'settings.json', + ); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '{"disableAllHooks":true}\n'; + const source = `${prepareSource} + import { rename, writeFile } from 'node:fs/promises'; + import { commitCopilotInstallation } from ${JSON.stringify(installationModuleUrl)}; + let calls = 0; + await commitCopilotInstallation(prepared, async (from, to) => { + calls += 1; + await rename(from, to); + if (calls === 3) { + await writeFile(process.env.ELYDORA_SETTINGS_PATH, process.env.ELYDORA_CONCURRENT); + } + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + ...transactionEnvironment(fixture, { orgId: 'org-updated' }), + ELYDORA_SETTINGS_PATH: settingsPath, + ELYDORA_CONCURRENT: concurrent, + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /settings changed during GitHub Copilot CLI installation/i); + await assertSnapshot(before); + assert.equal(await readFile(settingsPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Copilot rejects same-content stale snapshots by physical file identity', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const source = `${readAndRenderSource} + import { readFile, rm, writeFile } from 'node:fs/promises'; + const original = await readFile(sources.user.filePath, 'utf-8'); + await rm(sources.user.filePath); + await writeFile(sources.user.filePath, original); + const paths = await preflightCopilotInstallation(config, sources); + const hooks = removeManagedHooks(sources.user.hooks); + hooks.preToolUse = [buildHandler(paths.guardPath)]; + hooks.postToolUse = [buildHandler(paths.auditPath)]; + hooks.postToolUseFailure = [buildHandler(paths.auditPath)]; + const rendered = [renderDocument(sources.user, hooks)]; + await prepareCopilotInstallation(config, sources, rendered); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + transactionEnvironment(fixture), + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed before installation/i); + await assertSnapshot(before); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Copilot preserves orphaned runtime artifacts without a verifiable identity', async () => { + const fixture = await createFixture(); + try { + await mkdir(fixture.agentDir, { recursive: true }); + await writeFile(fixture.guardScriptPath, 'orphaned guard\n'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /identity cannot be verified/i); + assert.equal(await readFile(fixture.guardScriptPath, 'utf-8'), 'orphaned guard\n'); + await assertMissing(fixture.configPath); + } finally { + await fixture.close(); + } +}); + +test('Copilot uninstall restores both hook documents when the second removal fails', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const installed = await readFile(fixture.configPath, 'utf-8'); + await writeJson(fixture.legacyPath, installed); + const before = await snapshotInstallation(fixture, true); + const source = ` + import { removeManagedHooks, renderDocument } from ${JSON.stringify(contractModuleUrl)}; + import { readSources } from ${JSON.stringify(ioModuleUrl)}; + import { + commitCopilotUninstall, + prepareCopilotUninstall, + } from ${JSON.stringify(installationModuleUrl)}; + import { rename } from 'node:fs/promises'; + const sources = await readSources(); + const rendered = [ + renderDocument(sources.user, removeManagedHooks(sources.user.hooks, 'agent-1')), + renderDocument(sources.legacy, removeManagedHooks(sources.legacy.hooks, 'agent-1')), + ]; + const prepared = await prepareCopilotUninstall(rendered); + let calls = 0; + await commitCopilotUninstall(prepared, async (from, to) => { + calls += 1; + if (calls === 2) throw new Error('injected Copilot uninstall failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + environment(fixture), + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected Copilot uninstall failure/i); + await assertSnapshot(before); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Copilot detects legacy migration snapshots that change before preparation', async () => { + const fixture = await createFixture(); + try { + await writeJson(fixture.legacyPath, legacyManagedConfig(fixture)); + const original = await readFile(fixture.legacyPath, 'utf-8'); + const source = `${readAndRenderSource} + import { writeFile } from 'node:fs/promises'; + await writeFile(sources.legacy.filePath, process.env.ELYDORA_CHANGED); + const paths = await preflightCopilotInstallation(config, sources); + const hooks = removeManagedHooks(sources.user.hooks); + hooks.preToolUse = [buildHandler(paths.guardPath)]; + hooks.postToolUse = [buildHandler(paths.auditPath)]; + hooks.postToolUseFailure = [buildHandler(paths.auditPath)]; + const rendered = [ + renderDocument(sources.user, hooks), + renderDocument(sources.legacy, removeManagedHooks(sources.legacy.hooks)), + ]; + await prepareCopilotInstallation(config, sources, rendered); + `; + const changed = original.replace('"version": 1', '"version": 1,\n "owner": "concurrent"'); + const result = await runNode( + ['--input-type=module', '--eval', source], + { ...transactionEnvironment(fixture), ELYDORA_CHANGED: changed }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed before installation/i); + assert.equal(await readFile(fixture.legacyPath, 'utf-8'), changed); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } +}); diff --git a/test/integration-catalog-contracts.test.mjs b/test/integration-catalog-contracts.test.mjs index 638df8d..3fba799 100644 --- a/test/integration-catalog-contracts.test.mjs +++ b/test/integration-catalog-contracts.test.mjs @@ -120,11 +120,14 @@ test('high-drift providers retain their verified hook contracts', async () => { assert.deepEqual(providers.get('copilot').events, { before_tool: 'preToolUse', after_tool: 'postToolUse', + after_tool_failure: 'postToolUseFailure', }); assert.deepEqual(providers.get('copilot').event_fields, { name: 'toolName', input: 'toolArgs', session: 'sessionId', + output: 'toolResult', + error: 'error', }); assert.deepEqual(providers.get('copilot').blocking, { mechanism: 'any_nonzero_exit',