fix(go): mirror official Copilot hook contract
This commit is contained in:
1 parent
9b67aad113
commit
42a7653a77
15 files changed
+2191
-459
No files matched your search
+1
-1
@@ -84,7 +84,7 @@ Cursor installation writes native global `preToolUse`, `postToolUse`, and `postT
|
|||||||
|
|
||||||
Gemini CLI installation writes exact matchless `BeforeTool` and `AfterTool` command handlers to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Elydora preserves JSONC comments, line endings, unrelated user hooks, and additive workspace, system, system-override, and extension sources. The user settings document and all four generated runtime artifacts commit in one rollback-capable transaction. Generated hooks retain Gemini's native snake_case payload, emit `{}` on successful or fail-open execution, surface runtime failures through stderr and `error.log`, and use exit code `2` for frozen or revoked agents. Installation honors `hooksConfig` controls and uses encoded PowerShell commands on Windows. Run `/hooks list` after installation to inspect the effective hook inventory.
|
Gemini CLI installation writes exact matchless `BeforeTool` and `AfterTool` command handlers to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Elydora preserves JSONC comments, line endings, unrelated user hooks, and additive workspace, system, system-override, and extension sources. The user settings document and all four generated runtime artifacts commit in one rollback-capable transaction. Generated hooks retain Gemini's native snake_case payload, emit `{}` on successful or fail-open execution, surface runtime failures through stderr and `error.log`, and use exit code `2` for frozen or revoked agents. Installation honors `hooksConfig` controls and uses encoded PowerShell commands on Windows. Run `/hooks list` after installation to inspect the effective hook inventory.
|
||||||
|
|
||||||
GitHub Copilot CLI installation writes native user hooks to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves user hooks, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, quotes the resolved Node.js runtime for each shell, and propagates the freeze guard's exit code. Installation requires `disableAllHooks` to be false.
|
GitHub Copilot CLI installation writes native `preToolUse`, `postToolUse`, and `postToolUseFailure` user hooks to `$COPILOT_HOME/hooks/elydora-audit.json`, with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves additive hook sources, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, validates the official handler schema and JavaScript matcher syntax, resolves `disableAllHooks` through the CLI's settings precedence, and commits all runtime and hook files transactionally. The generated commands preserve native camelCase payloads and the guard propagates exit code `2`; restart active Copilot sessions after installation.
|
||||||
|
|
||||||
Cline 3.0.46 installation commits `guard.js`, `config.json`, `private.key`, `hook.js`, `PreToolUse.mjs`, and `PostToolUse.mjs` through one rollback-capable transaction. The native hooks live in `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; the Documents and workspace roots remain read-only. The wrappers forward Cline's complete stdin payload byte-for-byte, use the active Node.js executable for generated runtimes, and emit pure JSON cancellation control for frozen or revoked agents. Status requires physical files, exact generated sources, strict runtime identity, and a canonical private key.
|
Cline 3.0.46 installation commits `guard.js`, `config.json`, `private.key`, `hook.js`, `PreToolUse.mjs`, and `PostToolUse.mjs` through one rollback-capable transaction. The native hooks live in `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; the Documents and workspace roots remain read-only. The wrappers forward Cline's complete stdin payload byte-for-byte, use the active Node.js executable for generated runtimes, and emit pure JSON cancellation control for frozen or revoked agents. Status requires physical files, exact generated sources, strict runtime identity, and a canonical private key.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,131 +10,214 @@ type CopilotPlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *CopilotPlugin) Install(config InstallConfig) error {
|
// ManagesGuardRuntime reports that Copilot commits its provider guard with the
|
||||||
if config.AgentID == "" {
|
// audit runtime and hook documents.
|
||||||
return fmt.Errorf("agent ID is required")
|
func (p *CopilotPlugin) ManagesGuardRuntime() bool {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
sources, err := readCopilotSources()
|
|
||||||
|
// PreflightInstall validates hook sources, settings, matchers, credentials,
|
||||||
|
// and runtime identity before any write.
|
||||||
|
func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error {
|
||||||
|
sources, _, err := readCopilotSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if sources.user.disabled {
|
_, _, err = preflightCopilotInstallation(config, sources)
|
||||||
return fmt.Errorf(
|
|
||||||
"GitHub Copilot user hooks are disabled by disableAllHooks at %s",
|
|
||||||
sources.user.filePath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
expectedGuard := filepath.Join(agentDirectory, copilotGuardScript)
|
|
||||||
if !sameCopilotPath(config.GuardScriptPath, expectedGuard) {
|
|
||||||
return fmt.Errorf("Elydora guard runtime must use the managed agent directory: %s", expectedGuard)
|
|
||||||
}
|
|
||||||
if err := requireCopilotRuntime(expectedGuard, "Elydora guard runtime"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
auditPath := filepath.Join(agentDirectory, copilotAuditScript)
|
|
||||||
if config.HookScript != "" && !sameCopilotPath(config.HookScript, auditPath) {
|
|
||||||
return fmt.Errorf("Elydora audit runtime must use the managed agent directory: %s", auditPath)
|
|
||||||
}
|
|
||||||
nodePath, err := resolveNodeRuntime()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
userHooks := removeManagedCopilotHooks(sources.user.hooks, runtimeRoot, "")
|
func renderCopilotInstallation(
|
||||||
userHooks["preToolUse"] = append(
|
sources *copilotSources,
|
||||||
userHooks["preToolUse"],
|
guardPath string,
|
||||||
buildCopilotHandler(nodePath, expectedGuard),
|
auditPath string,
|
||||||
)
|
nodePath string,
|
||||||
userHooks["postToolUse"] = append(
|
) ([]*copilotRenderedDocument, error) {
|
||||||
userHooks["postToolUse"],
|
hooks, err := removeManagedCopilotHooks(sources.user.hooks, "")
|
||||||
buildCopilotHandler(nodePath, auditPath),
|
|
||||||
)
|
|
||||||
userRendered, err := renderCopilotDocument(sources.user, userHooks)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("render GitHub Copilot user hooks: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
rendered := []*copilotRenderedDocument{userRendered}
|
for _, item := range []struct {
|
||||||
|
event string
|
||||||
|
scriptPath string
|
||||||
|
}{
|
||||||
|
{"preToolUse", guardPath},
|
||||||
|
{"postToolUse", auditPath},
|
||||||
|
{"postToolUseFailure", auditPath},
|
||||||
|
} {
|
||||||
|
hooks[item.event] = append(
|
||||||
|
hooks[item.event],
|
||||||
|
buildCopilotHandler(nodePath, item.scriptPath),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
user, err := renderCopilotDocument(sources.user, hooks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render GitHub Copilot user hooks: %w", err)
|
||||||
|
}
|
||||||
|
rendered := []*copilotRenderedDocument{user}
|
||||||
if sources.legacy != nil {
|
if sources.legacy != nil {
|
||||||
legacyHooks := removeManagedCopilotHooks(sources.legacy.hooks, runtimeRoot, "")
|
legacyHooks, removeErr := removeManagedCopilotHooks(sources.legacy.hooks, "")
|
||||||
legacyRendered, renderErr := renderCopilotDocument(sources.legacy, legacyHooks)
|
if removeErr != nil {
|
||||||
|
return nil, removeErr
|
||||||
|
}
|
||||||
|
legacy, renderErr := renderCopilotDocument(sources.legacy, legacyHooks)
|
||||||
if renderErr != nil {
|
if renderErr != nil {
|
||||||
return fmt.Errorf("render GitHub Copilot project hooks: %w", renderErr)
|
return nil, fmt.Errorf("render GitHub Copilot legacy project hooks: %w", renderErr)
|
||||||
}
|
}
|
||||||
rendered = append(rendered, legacyRendered)
|
rendered = append(rendered, legacy)
|
||||||
}
|
}
|
||||||
changes, err := prepareCopilotInstallationChanges(
|
return rendered, nil
|
||||||
config,
|
}
|
||||||
agentDirectory,
|
|
||||||
auditPath,
|
func (p *CopilotPlugin) Install(config InstallConfig) error {
|
||||||
rendered,
|
sources, _, err := readCopilotSources()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
paths, nodePath, err := preflightCopilotInstallation(config, sources)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rendered, err := renderCopilotInstallation(
|
||||||
|
sources,
|
||||||
|
paths.guardPath,
|
||||||
|
paths.auditPath,
|
||||||
|
nodePath,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeChanges(changes, "Install GitHub Copilot hooks", p.rename); err != nil {
|
prepared, err := prepareCopilotInstallation(config, sources, rendered)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := commitCopilotInstallation(prepared, p.rename); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("GitHub Copilot CLI hooks: %s\n", sources.user.filePath)
|
fmt.Printf("GitHub Copilot CLI hooks: %s\n", sources.user.filePath)
|
||||||
|
fmt.Println("GitHub Copilot CLI: restart active sessions to load updated hooks.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *CopilotPlugin) Uninstall(agentID string) error {
|
func renderCopilotUninstall(
|
||||||
sources, err := readCopilotSources()
|
sources *copilotSources,
|
||||||
if err != nil {
|
agentID string,
|
||||||
return err
|
) ([]*copilotRenderedDocument, error) {
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
documents := []*copilotDocument{sources.user}
|
documents := []*copilotDocument{sources.user}
|
||||||
if sources.legacy != nil {
|
if sources.legacy != nil {
|
||||||
documents = append(documents, sources.legacy)
|
documents = append(documents, sources.legacy)
|
||||||
}
|
}
|
||||||
changes := make([]*fileChange, 0, len(documents))
|
rendered := make([]*copilotRenderedDocument, 0, len(documents))
|
||||||
for _, document := range documents {
|
for _, document := range documents {
|
||||||
hooks := removeManagedCopilotHooks(document.hooks, runtimeRoot, agentID)
|
hooks, err := removeManagedCopilotHooks(document.hooks, agentID)
|
||||||
rendered, renderErr := renderCopilotDocument(document, hooks)
|
if err != nil {
|
||||||
if renderErr != nil {
|
return nil, err
|
||||||
return fmt.Errorf("render GitHub Copilot hook source: %w", renderErr)
|
|
||||||
}
|
}
|
||||||
change, changeErr := prepareRenderedCopilotChange(rendered)
|
item, err := renderCopilotDocument(document, hooks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render GitHub Copilot hook source: %w", err)
|
||||||
|
}
|
||||||
|
rendered = append(rendered, item)
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CopilotPlugin) Uninstall(agentID string) error {
|
||||||
|
sources, _, err := readCopilotSources()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rendered, err := renderCopilotUninstall(sources, agentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changes := make([]*fileChange, 0, len(rendered))
|
||||||
|
for _, document := range rendered {
|
||||||
|
change, changeErr := prepareRenderedCopilotChange(document)
|
||||||
if changeErr != nil {
|
if changeErr != nil {
|
||||||
return changeErr
|
return changeErr
|
||||||
}
|
}
|
||||||
changes = append(changes, change)
|
changes = append(changes, change)
|
||||||
}
|
}
|
||||||
return writeChanges(changes, "Uninstall GitHub Copilot hooks", p.rename)
|
return writeCopilotChanges(
|
||||||
|
changes,
|
||||||
|
"Uninstall GitHub Copilot hooks",
|
||||||
|
p.rename,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergedCopilotContracts(
|
||||||
|
sources *copilotSources,
|
||||||
|
) ([]copilotRuntimeContract, error) {
|
||||||
|
contracts, err := copilotRuntimeContracts(sources.user.hooks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sources.legacy != nil {
|
||||||
|
legacy, legacyErr := copilotRuntimeContracts(sources.legacy.hooks)
|
||||||
|
if legacyErr != nil {
|
||||||
|
return nil, legacyErr
|
||||||
|
}
|
||||||
|
contracts = append(contracts, legacy...)
|
||||||
|
}
|
||||||
|
unique := map[string]copilotRuntimeContract{}
|
||||||
|
for _, contract := range contracts {
|
||||||
|
unique[copilotEntryKey(contract.agentID)] = contract
|
||||||
|
}
|
||||||
|
result := make([]copilotRuntimeContract, 0, len(unique))
|
||||||
|
for _, contract := range unique {
|
||||||
|
result = append(result, contract)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configuredCopilotPath(
|
||||||
|
sources *copilotSources,
|
||||||
|
defaultPath string,
|
||||||
|
) (string, error) {
|
||||||
|
userContracts, err := copilotRuntimeContracts(sources.user.hooks)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(userContracts) > 0 {
|
||||||
|
return sources.user.filePath, nil
|
||||||
|
}
|
||||||
|
if sources.legacy != nil {
|
||||||
|
legacyContracts, legacyErr := copilotRuntimeContracts(sources.legacy.hooks)
|
||||||
|
if legacyErr != nil {
|
||||||
|
return "", legacyErr
|
||||||
|
}
|
||||||
|
if len(legacyContracts) > 0 {
|
||||||
|
return sources.legacy.filePath, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Clean(defaultPath), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *CopilotPlugin) Status() (PluginStatus, error) {
|
func (p *CopilotPlugin) Status() (PluginStatus, error) {
|
||||||
userPath, _, pathErr := copilotConfigPaths()
|
paths, pathErr := resolveCopilotPaths()
|
||||||
entry := SupportedAgents[copilotAgentKey]
|
entry := SupportedAgents[copilotAgentKey]
|
||||||
status := PluginStatus{
|
status := PluginStatus{AgentName: copilotAgentKey, DisplayName: entry.Name}
|
||||||
AgentName: copilotAgentKey, DisplayName: entry.Name, ConfigPath: userPath,
|
|
||||||
}
|
|
||||||
if pathErr != nil {
|
if pathErr != nil {
|
||||||
return status, pathErr
|
return status, pathErr
|
||||||
}
|
}
|
||||||
sources, err := readCopilotSources()
|
status.ConfigPath = paths.userHookPath
|
||||||
|
sources, _, err := readCopilotSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
contracts, err := mergedCopilotContracts(sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
contracts := activeCopilotContracts(sources, runtimeRoot)
|
status.ConfigPath, err = configuredCopilotPath(sources, paths.userHookPath)
|
||||||
status.ConfigPath = configuredCopilotPath(sources, runtimeRoot)
|
if err != nil {
|
||||||
status.HookConfigured = len(contracts) > 0
|
return status, err
|
||||||
|
}
|
||||||
|
status.HookConfigured = sources.disabledBy == "" && len(contracts) > 0
|
||||||
if !status.HookConfigured {
|
if !status.HookConfigured {
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
@@ -146,20 +228,3 @@ func (p *CopilotPlugin) Status() (PluginStatus, error) {
|
|||||||
status.Installed = status.HookScriptExists
|
status.Installed = status.HookScriptExists
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireCopilotRuntime(path, label string) error {
|
|
||||||
if path == "" {
|
|
||||||
return fmt.Errorf("%s path is required", label)
|
|
||||||
}
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("%s is missing: %s", label, path)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read %s at %s: %w", label, path, err)
|
|
||||||
}
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
||||||
return fmt.Errorf("%s path is not a physical file: %s", label, path)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -18,6 +20,15 @@ const (
|
|||||||
copilotPOSIXApostrophe = `'"'"'`
|
copilotPOSIXApostrophe = `'"'"'`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var copilotManagedEvents = []struct {
|
||||||
|
event string
|
||||||
|
script string
|
||||||
|
}{
|
||||||
|
{"preToolUse", copilotGuardScript},
|
||||||
|
{"postToolUse", copilotAuditScript},
|
||||||
|
{"postToolUseFailure", copilotAuditScript},
|
||||||
|
}
|
||||||
|
|
||||||
type copilotHooks map[string][]map[string]any
|
type copilotHooks map[string][]map[string]any
|
||||||
|
|
||||||
type copilotDocument struct {
|
type copilotDocument struct {
|
||||||
@@ -26,12 +37,21 @@ type copilotDocument struct {
|
|||||||
root map[string]any
|
root map[string]any
|
||||||
hooks copilotHooks
|
hooks copilotHooks
|
||||||
raw []byte
|
raw []byte
|
||||||
disabled bool
|
snapshot *managedFileSnapshot
|
||||||
|
hooksDisabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type copilotSourcePrecondition struct {
|
||||||
|
filePath string
|
||||||
|
label string
|
||||||
|
snapshot *managedFileSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
type copilotSources struct {
|
type copilotSources struct {
|
||||||
user *copilotDocument
|
user *copilotDocument
|
||||||
legacy *copilotDocument
|
legacy *copilotDocument
|
||||||
|
disabledBy string
|
||||||
|
settingsPreconditions []copilotSourcePrecondition
|
||||||
}
|
}
|
||||||
|
|
||||||
type copilotRenderedDocument struct {
|
type copilotRenderedDocument struct {
|
||||||
@@ -47,10 +67,25 @@ type copilotRuntimeContract struct {
|
|||||||
auditPath string
|
auditPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type copilotManagedEntry struct {
|
||||||
|
agentID string
|
||||||
|
scriptPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCopilotObject(value map[string]any) map[string]any {
|
||||||
|
clone := make(map[string]any, len(value))
|
||||||
|
for key, item := range value {
|
||||||
|
clone[key] = item
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
func cloneCopilotHooks(source copilotHooks) copilotHooks {
|
func cloneCopilotHooks(source copilotHooks) copilotHooks {
|
||||||
clone := make(copilotHooks, len(source))
|
clone := make(copilotHooks, len(source))
|
||||||
for event, handlers := range source {
|
for event, handlers := range source {
|
||||||
clone[event] = append([]map[string]any(nil), handlers...)
|
copied := make([]map[string]any, len(handlers))
|
||||||
|
copy(copied, handlers)
|
||||||
|
clone[event] = copied
|
||||||
}
|
}
|
||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
@@ -77,6 +112,13 @@ func sameCopilotAgentID(left, right string) bool {
|
|||||||
return left == right
|
return left == right
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sameCopilotFileName(left, right string) bool {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return strings.EqualFold(left, right)
|
||||||
|
}
|
||||||
|
return left == right
|
||||||
|
}
|
||||||
|
|
||||||
func quoteCopilotPowerShell(value string) string {
|
func quoteCopilotPowerShell(value string) string {
|
||||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||||
}
|
}
|
||||||
@@ -198,48 +240,113 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) {
|
|||||||
return parseCopilotLegacyCommand(bash)
|
return parseCopilotLegacyCommand(bash)
|
||||||
}
|
}
|
||||||
|
|
||||||
func copilotManagedAgentID(
|
func copilotManagedEntryForHandler(
|
||||||
handler map[string]any,
|
handler map[string]any,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
runtimeRoot string,
|
) (*copilotManagedEntry, error) {
|
||||||
) (string, bool) {
|
|
||||||
scriptPath, managed := copilotManagedScriptPath(handler)
|
scriptPath, managed := copilotManagedScriptPath(handler)
|
||||||
if !managed || !filepath.IsAbs(scriptPath) || filepath.Base(scriptPath) != scriptName {
|
if !managed || !filepath.IsAbs(scriptPath) ||
|
||||||
return "", false
|
!sameCopilotFileName(filepath.Base(scriptPath), scriptName) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
agentDirectory := filepath.Dir(scriptPath)
|
||||||
if !sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
if !sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||||
return "", false
|
return nil, nil
|
||||||
}
|
}
|
||||||
agentID := filepath.Base(agentDirectory)
|
agentID := filepath.Base(agentDirectory)
|
||||||
return agentID, agentID != "" && agentID != "." && agentID != ".."
|
if agentID == "" || agentID == "." || agentID == ".." {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &copilotManagedEntry{agentID: agentID, scriptPath: scriptPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeManagedCopilotHooks(
|
func removeManagedCopilotHooks(
|
||||||
hooks copilotHooks,
|
hooks copilotHooks,
|
||||||
runtimeRoot string,
|
|
||||||
agentID string,
|
agentID string,
|
||||||
) copilotHooks {
|
) (copilotHooks, error) {
|
||||||
result := cloneCopilotHooks(hooks)
|
result := cloneCopilotHooks(hooks)
|
||||||
for _, contract := range []struct{ event, script string }{
|
for _, contract := range copilotManagedEvents {
|
||||||
{"preToolUse", copilotGuardScript},
|
|
||||||
{"postToolUse", copilotAuditScript},
|
|
||||||
} {
|
|
||||||
kept := make([]map[string]any, 0, len(result[contract.event]))
|
kept := make([]map[string]any, 0, len(result[contract.event]))
|
||||||
for _, handler := range result[contract.event] {
|
for _, handler := range result[contract.event] {
|
||||||
managedID, managed := copilotManagedAgentID(handler, contract.script, runtimeRoot)
|
entry, err := copilotManagedEntryForHandler(handler, contract.script)
|
||||||
if managed && (agentID == "" || sameCopilotAgentID(managedID, agentID)) {
|
if err != nil {
|
||||||
continue
|
return nil, err
|
||||||
}
|
}
|
||||||
|
remove := entry != nil &&
|
||||||
|
(agentID == "" || sameCopilotAgentID(entry.agentID, agentID))
|
||||||
|
if !remove {
|
||||||
kept = append(kept, handler)
|
kept = append(kept, handler)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if len(kept) == 0 {
|
if len(kept) == 0 {
|
||||||
delete(result, contract.event)
|
delete(result, contract.event)
|
||||||
} else {
|
} else {
|
||||||
result[contract.event] = kept
|
result[contract.event] = kept
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCopilotDocument(
|
||||||
|
filePath string,
|
||||||
|
snapshot *managedFileSnapshot,
|
||||||
|
label string,
|
||||||
|
) (*copilotDocument, error) {
|
||||||
|
if snapshot == nil {
|
||||||
|
return nil, fmt.Errorf("%s snapshot is required: %s", label, filePath)
|
||||||
|
}
|
||||||
|
documentLabel := fmt.Sprintf("%s at %s", label, filePath)
|
||||||
|
root, err := decodeStrictJSONObject(snapshot.contents, documentLabel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if root["version"] != float64(1) {
|
||||||
|
return nil, fmt.Errorf("%s must declare version 1", documentLabel)
|
||||||
|
}
|
||||||
|
disabled := false
|
||||||
|
if value, exists := root["disableAllHooks"]; exists {
|
||||||
|
var valid bool
|
||||||
|
disabled, valid = value.(bool)
|
||||||
|
if !valid {
|
||||||
|
return nil, fmt.Errorf(`%s field "disableAllHooks" must be a boolean`, documentLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hooks := copilotHooks{}
|
||||||
|
if value, exists := root["hooks"]; exists {
|
||||||
|
hooks, err = validateCopilotHooks(value, documentLabel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &copilotDocument{
|
||||||
|
exists: true, filePath: filePath, root: root, hooks: hooks,
|
||||||
|
raw: append([]byte(nil), snapshot.contents...), snapshot: snapshot,
|
||||||
|
hooksDisabled: disabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCopilotDocument(filePath string) *copilotDocument {
|
||||||
|
return &copilotDocument{
|
||||||
|
filePath: filePath,
|
||||||
|
root: map[string]any{},
|
||||||
|
hooks: copilotHooks{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyOwnedCopilotDocument(root map[string]any, hooks copilotHooks) bool {
|
||||||
|
if len(hooks) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for key := range root {
|
||||||
|
if key != "version" && key != "hooks" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderCopilotDocument(
|
func renderCopilotDocument(
|
||||||
@@ -249,22 +356,12 @@ func renderCopilotDocument(
|
|||||||
if !document.exists && len(hooks) == 0 {
|
if !document.exists && len(hooks) == 0 {
|
||||||
return &copilotRenderedDocument{document: document}, nil
|
return &copilotRenderedDocument{document: document}, nil
|
||||||
}
|
}
|
||||||
if document.exists && len(hooks) == 0 {
|
if document.exists && emptyOwnedCopilotDocument(document.root, hooks) {
|
||||||
owned := true
|
return &copilotRenderedDocument{
|
||||||
for key := range document.root {
|
document: document, changed: true, remove: true,
|
||||||
if key != "version" && key != "hooks" {
|
}, nil
|
||||||
owned = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if owned {
|
|
||||||
return &copilotRenderedDocument{document: document, changed: true, remove: true}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
root := make(map[string]any, len(document.root)+1)
|
|
||||||
for key, value := range document.root {
|
|
||||||
root[key] = value
|
|
||||||
}
|
}
|
||||||
|
root := cloneCopilotObject(document.root)
|
||||||
root["version"] = float64(1)
|
root["version"] = float64(1)
|
||||||
if len(hooks) == 0 {
|
if len(hooks) == 0 {
|
||||||
delete(root, "hooks")
|
delete(root, "hooks")
|
||||||
@@ -273,58 +370,81 @@ func renderCopilotDocument(
|
|||||||
}
|
}
|
||||||
next, err := json.MarshalIndent(root, "", " ")
|
next, err := json.MarshalIndent(root, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("encode GitHub Copilot hooks: %w", err)
|
||||||
}
|
}
|
||||||
next = append(next, '\n')
|
next = append(next, '\n')
|
||||||
|
if _, err := parseCopilotDocument(
|
||||||
|
document.filePath,
|
||||||
|
&managedFileSnapshot{contents: next},
|
||||||
|
"GitHub Copilot rendered hooks",
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate rendered GitHub Copilot hooks: %w", err)
|
||||||
|
}
|
||||||
return &copilotRenderedDocument{
|
return &copilotRenderedDocument{
|
||||||
document: document,
|
document: document,
|
||||||
changed: !document.exists || string(next) != string(document.raw),
|
changed: !document.exists || !bytes.Equal(next, document.raw),
|
||||||
next: next,
|
next: next,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copilotRuntimeContracts(
|
func copilotEntryKey(agentID string) string {
|
||||||
hooks copilotHooks,
|
if runtime.GOOS == "windows" {
|
||||||
runtimeRoot string,
|
return strings.ToLower(agentID)
|
||||||
) []copilotRuntimeContract {
|
}
|
||||||
guards := managedCopilotIDs(hooks["preToolUse"], copilotGuardScript, runtimeRoot)
|
return agentID
|
||||||
audits := managedCopilotIDs(hooks["postToolUse"], copilotAuditScript, runtimeRoot)
|
}
|
||||||
|
|
||||||
|
func copilotManagedEntries(
|
||||||
|
handlers []map[string]any,
|
||||||
|
scriptName string,
|
||||||
|
) (map[string][]copilotManagedEntry, error) {
|
||||||
|
result := map[string][]copilotManagedEntry{}
|
||||||
|
for _, handler := range handlers {
|
||||||
|
entry, err := copilotManagedEntryForHandler(handler, scriptName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if entry != nil {
|
||||||
|
key := copilotEntryKey(entry.agentID)
|
||||||
|
result[key] = append(result[key], *entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copilotRuntimeContracts(hooks copilotHooks) ([]copilotRuntimeContract, error) {
|
||||||
|
guards, err := copilotManagedEntries(hooks["preToolUse"], copilotGuardScript)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
successes, err := copilotManagedEntries(hooks["postToolUse"], copilotAuditScript)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
failures, err := copilotManagedEntries(
|
||||||
|
hooks["postToolUseFailure"], copilotAuditScript,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
keys := make([]string, 0, len(guards))
|
keys := make([]string, 0, len(guards))
|
||||||
for key := range guards {
|
for key := range guards {
|
||||||
if _, exists := audits[key]; exists {
|
|
||||||
keys = append(keys, key)
|
keys = append(keys, key)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
contracts := make([]copilotRuntimeContract, 0, len(keys))
|
contracts := make([]copilotRuntimeContract, 0, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
agentID := guards[key]
|
guard := guards[key]
|
||||||
agentDirectory := filepath.Join(runtimeRoot, agentID)
|
success := successes[key]
|
||||||
contracts = append(contracts, copilotRuntimeContract{
|
failure := failures[key]
|
||||||
agentID: agentID,
|
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||||
guardPath: filepath.Join(agentDirectory, copilotGuardScript),
|
!sameCopilotPath(success[0].scriptPath, failure[0].scriptPath) {
|
||||||
auditPath: filepath.Join(agentDirectory, copilotAuditScript),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return contracts
|
|
||||||
}
|
|
||||||
|
|
||||||
func managedCopilotIDs(
|
|
||||||
handlers []map[string]any,
|
|
||||||
scriptName string,
|
|
||||||
runtimeRoot string,
|
|
||||||
) map[string]string {
|
|
||||||
result := map[string]string{}
|
|
||||||
for _, handler := range handlers {
|
|
||||||
agentID, managed := copilotManagedAgentID(handler, scriptName, runtimeRoot)
|
|
||||||
if !managed {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := agentID
|
contracts = append(contracts, copilotRuntimeContract{
|
||||||
if runtime.GOOS == "windows" {
|
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
||||||
key = strings.ToLower(agentID)
|
auditPath: success[0].scriptPath,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
result[key] = agentID
|
return contracts, nil
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type copilotRuntimePaths struct {
|
||||||
|
runtimeRoot string
|
||||||
|
agentDirectory string
|
||||||
|
configPath string
|
||||||
|
keyPath string
|
||||||
|
guardPath string
|
||||||
|
auditPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type preparedCopilotInstallation struct {
|
||||||
|
changes []*fileChange
|
||||||
|
preconditions []filePrecondition
|
||||||
|
paths *copilotRuntimePaths
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotInstallConfig(config InstallConfig) error {
|
||||||
|
for _, field := range []struct{ name, value string }{
|
||||||
|
{"agent name", config.AgentName},
|
||||||
|
{"organization ID", config.OrgID},
|
||||||
|
{"agent ID", config.AgentID},
|
||||||
|
{"key ID", config.KID},
|
||||||
|
{"private key", config.PrivateKey},
|
||||||
|
{"base URL", config.BaseURL},
|
||||||
|
{"guard script path", config.GuardScriptPath},
|
||||||
|
} {
|
||||||
|
if field.value == "" {
|
||||||
|
return fmt.Errorf("%s is required", field.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.AgentName != copilotAgentKey {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"GitHub Copilot CLI installation requires agent name %s",
|
||||||
|
copilotAgentKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.OrgID) == "" {
|
||||||
|
return fmt.Errorf("organization ID is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.KID) == "" {
|
||||||
|
return fmt.Errorf("key ID is required")
|
||||||
|
}
|
||||||
|
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
||||||
|
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
||||||
|
}
|
||||||
|
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateManagedBaseURL(config.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copilotAgentPaths(config InstallConfig) (*copilotRuntimePaths, error) {
|
||||||
|
if err := validateCopilotInstallConfig(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paths := &copilotRuntimePaths{
|
||||||
|
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
||||||
|
configPath: filepath.Join(agentDirectory, "config.json"),
|
||||||
|
keyPath: filepath.Join(agentDirectory, "private.key"),
|
||||||
|
guardPath: filepath.Join(agentDirectory, copilotGuardScript),
|
||||||
|
auditPath: filepath.Join(agentDirectory, copilotAuditScript),
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(config.GuardScriptPath) ||
|
||||||
|
!sameCopilotPath(config.GuardScriptPath, paths.guardPath) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"Elydora guard runtime must use the managed agent directory: %s",
|
||||||
|
paths.guardPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
||||||
|
!sameCopilotPath(config.HookScript, paths.auditPath)) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"Elydora audit runtime must use the managed agent directory: %s",
|
||||||
|
paths.auditPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func preflightCopilotInstallation(
|
||||||
|
config InstallConfig,
|
||||||
|
sources *copilotSources,
|
||||||
|
) (*copilotRuntimePaths, string, error) {
|
||||||
|
if sources == nil || sources.user == nil || sources.user.filePath == "" {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"GitHub Copilot CLI installation requires a user hook path",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err := requireCopilotHooksEnabled(sources); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := copilotAgentPaths(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := validateCopilotRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
nodePath, err := resolveNodeRuntime()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(nodePath) || !isCopilotNodeExecutable(nodePath) {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"GitHub Copilot CLI hooks require an absolute Node.js executable path",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hookSources := []copilotHooks{sources.user.hooks}
|
||||||
|
if sources.legacy != nil {
|
||||||
|
hookSources = append(hookSources, sources.legacy.hooks)
|
||||||
|
}
|
||||||
|
if err := validateCopilotJavaScriptMatchers(hookSources, nodePath); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return paths, nodePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCopilotRuntimeConfig(config InstallConfig) ([]byte, error) {
|
||||||
|
value := map[string]any{
|
||||||
|
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
||||||
|
"base_url": config.BaseURL, "agent_name": copilotAgentKey,
|
||||||
|
}
|
||||||
|
if config.Token != "" {
|
||||||
|
value["token"] = config.Token
|
||||||
|
}
|
||||||
|
encoded, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
||||||
|
}
|
||||||
|
encoded = append(encoded, '\n')
|
||||||
|
if len(encoded) > maxRuntimeConfigBytes {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
||||||
|
maxRuntimeConfigBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return encoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotRenderedSet(
|
||||||
|
sources *copilotSources,
|
||||||
|
rendered []*copilotRenderedDocument,
|
||||||
|
) error {
|
||||||
|
expected := []*copilotDocument{sources.user}
|
||||||
|
if sources.legacy != nil {
|
||||||
|
expected = append(expected, sources.legacy)
|
||||||
|
}
|
||||||
|
if len(rendered) != len(expected) {
|
||||||
|
return fmt.Errorf("GitHub Copilot rendered source set is incomplete")
|
||||||
|
}
|
||||||
|
for _, document := range expected {
|
||||||
|
matches := 0
|
||||||
|
for _, item := range rendered {
|
||||||
|
if item != nil && item.document != nil &&
|
||||||
|
sameCopilotPath(item.document.filePath, document.filePath) {
|
||||||
|
matches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches != 1 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"GitHub Copilot rendered source set contains unexpected paths",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copilotInstallationPreconditions(
|
||||||
|
sources *copilotSources,
|
||||||
|
rendered []*copilotRenderedDocument,
|
||||||
|
) []filePrecondition {
|
||||||
|
result := make([]filePrecondition, 0, len(sources.settingsPreconditions)+len(rendered))
|
||||||
|
for _, item := range sources.settingsPreconditions {
|
||||||
|
result = append(result, filePrecondition{
|
||||||
|
filePath: item.filePath, label: item.label,
|
||||||
|
snapshot: item.snapshot, maximumSize: maxManagedSourceBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, item := range rendered {
|
||||||
|
if item != nil && !item.changed {
|
||||||
|
result = append(result, filePrecondition{
|
||||||
|
filePath: item.document.filePath,
|
||||||
|
label: "GitHub Copilot hook source",
|
||||||
|
snapshot: item.document.snapshot,
|
||||||
|
maximumSize: maxManagedSourceBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareCopilotInstallation(
|
||||||
|
config InstallConfig,
|
||||||
|
sources *copilotSources,
|
||||||
|
rendered []*copilotRenderedDocument,
|
||||||
|
) (*preparedCopilotInstallation, error) {
|
||||||
|
paths, _, err := preflightCopilotInstallation(config, sources)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateCopilotRenderedSet(sources, rendered); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
runtimeConfig, err := buildCopilotRuntimeConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := []struct {
|
||||||
|
path, label string
|
||||||
|
content []byte
|
||||||
|
mode os.FileMode
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
paths.guardPath, "Elydora guard runtime",
|
||||||
|
[]byte(generateGuardScript(copilotAgentKey, config.AgentID, "", false, "")), 0700,
|
||||||
|
},
|
||||||
|
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
||||||
|
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
||||||
|
{
|
||||||
|
paths.auditPath, "Elydora audit runtime",
|
||||||
|
[]byte(buildHookScriptWithOutput(copilotAgentKey, config.AgentID, "", false, true)), 0700,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
changes := make([]*fileChange, 0, len(items)+len(rendered))
|
||||||
|
for _, item := range items {
|
||||||
|
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
|
||||||
|
if changeErr != nil {
|
||||||
|
return nil, changeErr
|
||||||
|
}
|
||||||
|
changes = append(changes, change)
|
||||||
|
}
|
||||||
|
for _, document := range rendered {
|
||||||
|
change, changeErr := prepareRenderedCopilotChange(document)
|
||||||
|
if changeErr != nil {
|
||||||
|
return nil, changeErr
|
||||||
|
}
|
||||||
|
changes = append(changes, change)
|
||||||
|
}
|
||||||
|
return &preparedCopilotInstallation{
|
||||||
|
changes: changes, paths: paths,
|
||||||
|
preconditions: copilotInstallationPreconditions(sources, rendered),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitCopilotInstallation(
|
||||||
|
prepared *preparedCopilotInstallation,
|
||||||
|
rename renameFunc,
|
||||||
|
) error {
|
||||||
|
if prepared == nil || prepared.paths == nil {
|
||||||
|
return fmt.Errorf("prepared GitHub Copilot installation is required")
|
||||||
|
}
|
||||||
|
return writeCopilotChanges(
|
||||||
|
prepared.changes,
|
||||||
|
"Install GitHub Copilot hooks",
|
||||||
|
rename,
|
||||||
|
prepared.paths.runtimeRoot,
|
||||||
|
prepared.paths.agentDirectory,
|
||||||
|
prepared.preconditions,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,141 +1,219 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func copilotConfigPaths() (string, string, error) {
|
type copilotSettingsLayer struct {
|
||||||
|
filePath string
|
||||||
|
label string
|
||||||
|
jsonc bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type copilotDirectoryLocation struct {
|
||||||
|
path string
|
||||||
|
label string
|
||||||
|
}
|
||||||
|
|
||||||
|
type copilotPaths struct {
|
||||||
|
copilotHome string
|
||||||
|
userHooksDirectory string
|
||||||
|
userHookPath string
|
||||||
|
legacyHookPath string
|
||||||
|
settingsLayers []copilotSettingsLayer
|
||||||
|
directories []copilotDirectoryLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
type parsedCopilotSettings struct {
|
||||||
|
layer copilotSettingsLayer
|
||||||
|
disabled *bool
|
||||||
|
snapshot *managedFileSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCopilotPaths() (*copilotPaths, error) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("resolve home directory: %w", err)
|
return nil, fmt.Errorf("resolve home directory: %w", err)
|
||||||
}
|
}
|
||||||
copilotHome := strings.TrimSpace(os.Getenv("COPILOT_HOME"))
|
override := os.Getenv("COPILOT_HOME")
|
||||||
if copilotHome == "" {
|
copilotHome := override
|
||||||
|
if strings.TrimSpace(override) == "" {
|
||||||
copilotHome = filepath.Join(home, ".copilot")
|
copilotHome = filepath.Join(home, ".copilot")
|
||||||
} else if !filepath.IsAbs(copilotHome) {
|
} else if !filepath.IsAbs(copilotHome) {
|
||||||
copilotHome, err = filepath.Abs(copilotHome)
|
copilotHome, err = filepath.Abs(copilotHome)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("resolve COPILOT_HOME: %w", err)
|
return nil, fmt.Errorf("resolve COPILOT_HOME at %s: %w", override, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
workingDirectory, err := os.Getwd()
|
project, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("resolve working directory: %w", err)
|
return nil, fmt.Errorf("resolve working directory: %w", err)
|
||||||
}
|
}
|
||||||
return filepath.Join(copilotHome, "hooks", copilotConfigFile),
|
github := filepath.Join(project, ".github")
|
||||||
filepath.Join(workingDirectory, ".github", "hooks", "hooks.json"), nil
|
githubCopilot := filepath.Join(github, "copilot")
|
||||||
}
|
githubHooks := filepath.Join(github, "hooks")
|
||||||
|
claude := filepath.Join(project, ".claude")
|
||||||
func readCopilotHooks(value any, label string) (copilotHooks, error) {
|
userHooks := filepath.Join(copilotHome, "hooks")
|
||||||
if value == nil {
|
return &copilotPaths{
|
||||||
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
|
copilotHome: copilotHome,
|
||||||
}
|
userHooksDirectory: userHooks,
|
||||||
object, ok := value.(map[string]any)
|
userHookPath: filepath.Join(userHooks, copilotConfigFile),
|
||||||
if !ok {
|
legacyHookPath: filepath.Join(githubHooks, "hooks.json"),
|
||||||
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
|
settingsLayers: []copilotSettingsLayer{
|
||||||
}
|
{filepath.Join(copilotHome, "config.json"), "legacy Copilot user config", false},
|
||||||
hooks := make(copilotHooks, len(object))
|
{filepath.Join(copilotHome, "settings.json"), "Copilot user settings", true},
|
||||||
for event, handlerValue := range object {
|
{filepath.Join(claude, "settings.json"), "Claude repository settings", true},
|
||||||
values, ok := handlerValue.([]any)
|
{filepath.Join(claude, "settings.local.json"), "Claude local settings", true},
|
||||||
if !ok {
|
{filepath.Join(githubCopilot, "settings.json"), "Copilot repository settings", true},
|
||||||
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event)
|
{filepath.Join(githubCopilot, "settings.local.json"), "Copilot local settings", true},
|
||||||
}
|
},
|
||||||
handlers := make([]map[string]any, 0, len(values))
|
directories: []copilotDirectoryLocation{
|
||||||
for index, value := range values {
|
{project, "Copilot working directory"},
|
||||||
handler, ok := value.(map[string]any)
|
{copilotHome, "COPILOT_HOME"},
|
||||||
if !ok || handler == nil {
|
{userHooks, "Copilot user hooks directory"},
|
||||||
return nil, fmt.Errorf(`%s handler hooks.%s[%d] must be an object`, label, event, index)
|
{github, "GitHub configuration directory"},
|
||||||
}
|
{githubHooks, "GitHub repository hooks directory"},
|
||||||
handlers = append(handlers, handler)
|
{githubCopilot, "Copilot repository settings directory"},
|
||||||
}
|
{claude, "Claude repository settings directory"},
|
||||||
hooks[event] = handlers
|
},
|
||||||
}
|
|
||||||
return hooks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCopilotDocument(
|
|
||||||
filePath string,
|
|
||||||
raw []byte,
|
|
||||||
label string,
|
|
||||||
) (*copilotDocument, error) {
|
|
||||||
var value any
|
|
||||||
if err := json.Unmarshal(raw, &value); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse %s at %s: %w", label, filePath, err)
|
|
||||||
}
|
|
||||||
root, ok := value.(map[string]any)
|
|
||||||
if !ok || root == nil {
|
|
||||||
return nil, fmt.Errorf("%s at %s must contain a JSON object", label, filePath)
|
|
||||||
}
|
|
||||||
if root["version"] != float64(1) {
|
|
||||||
return nil, fmt.Errorf("%s at %s must declare version 1", label, filePath)
|
|
||||||
}
|
|
||||||
hooks := copilotHooks{}
|
|
||||||
if hooksValue, exists := root["hooks"]; exists {
|
|
||||||
var err error
|
|
||||||
hooks, err = readCopilotHooks(hooksValue, label)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
disabled := false
|
|
||||||
if value, exists := root["disableAllHooks"]; exists {
|
|
||||||
var valid bool
|
|
||||||
disabled, valid = value.(bool)
|
|
||||||
if !valid {
|
|
||||||
return nil, fmt.Errorf(`%s field "disableAllHooks" must be a boolean`, label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &copilotDocument{
|
|
||||||
exists: true, filePath: filePath, root: root,
|
|
||||||
hooks: hooks, raw: append([]byte(nil), raw...), disabled: disabled,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readCopilotDocument(filePath, label string) (*copilotDocument, error) {
|
func inspectCopilotDirectories(locations []copilotDirectoryLocation) error {
|
||||||
info, err := os.Lstat(filePath)
|
for _, location := range locations {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if _, err := managedPhysicalDirectoryExists(location.path, location.label); err != nil {
|
||||||
return nil, nil
|
return err
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("inspect %s at %s: %w", label, filePath, err)
|
|
||||||
}
|
}
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
return nil
|
||||||
return nil, fmt.Errorf("%s path is not a physical file: %s", label, filePath)
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(filePath) // #nosec G304 -- filePath is a validated provider configuration path.
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read %s at %s: %w", label, filePath, err)
|
|
||||||
}
|
|
||||||
return parseCopilotDocument(filePath, raw, label)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readCopilotSources() (*copilotSources, error) {
|
func readCopilotDocument(filePath, label string) (*copilotDocument, error) {
|
||||||
userPath, legacyPath, err := copilotConfigPaths()
|
snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes)
|
||||||
if err != nil {
|
if err != nil || snapshot == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
user, err := readCopilotDocument(userPath, "GitHub Copilot user hooks")
|
return parseCopilotDocument(filePath, snapshot, label)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
legacy, err := readCopilotDocument(legacyPath, "GitHub Copilot project hooks")
|
|
||||||
|
func parseCopilotSettings(
|
||||||
|
snapshot *managedFileSnapshot,
|
||||||
|
layer copilotSettingsLayer,
|
||||||
|
) (map[string]any, error) {
|
||||||
|
if len(strings.TrimSpace(string(snapshot.contents))) == 0 {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s at %s", layer.label, layer.filePath)
|
||||||
|
if layer.jsonc {
|
||||||
|
return decodeJSONCObject(snapshot.contents, label, true)
|
||||||
|
}
|
||||||
|
return decodeStrictJSONObject(snapshot.contents, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCopilotSettingsLayer(
|
||||||
|
layer copilotSettingsLayer,
|
||||||
|
) (parsedCopilotSettings, error) {
|
||||||
|
snapshot, err := readManagedFile(
|
||||||
|
layer.filePath,
|
||||||
|
layer.label,
|
||||||
|
maxManagedSourceBytes,
|
||||||
|
)
|
||||||
|
if err != nil || snapshot == nil {
|
||||||
|
return parsedCopilotSettings{layer: layer}, err
|
||||||
|
}
|
||||||
|
root, err := parseCopilotSettings(snapshot, layer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return parsedCopilotSettings{}, err
|
||||||
|
}
|
||||||
|
result := parsedCopilotSettings{layer: layer, snapshot: snapshot}
|
||||||
|
if value, exists := root["disableAllHooks"]; exists {
|
||||||
|
disabled, ok := value.(bool)
|
||||||
|
if !ok {
|
||||||
|
return parsedCopilotSettings{}, fmt.Errorf(
|
||||||
|
`%s at %s field "disableAllHooks" must be a boolean`,
|
||||||
|
layer.label,
|
||||||
|
layer.filePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result.disabled = &disabled
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveCopilotDisabledSource(layers []parsedCopilotSettings) string {
|
||||||
|
disabledBy := ""
|
||||||
|
for _, layer := range layers {
|
||||||
|
if layer.disabled == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if *layer.disabled {
|
||||||
|
disabledBy = fmt.Sprintf("%s at %s", layer.layer.label, layer.layer.filePath)
|
||||||
|
} else {
|
||||||
|
disabledBy = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return disabledBy
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCopilotSources() (*copilotSources, *copilotPaths, error) {
|
||||||
|
paths, err := resolveCopilotPaths()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := inspectCopilotDirectories(paths.directories); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
user, err := readCopilotDocument(paths.userHookPath, "GitHub Copilot user hooks")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
legacy, err := readCopilotDocument(
|
||||||
|
paths.legacyHookPath,
|
||||||
|
"GitHub Copilot legacy project hooks",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
settings := make([]parsedCopilotSettings, 0, len(paths.settingsLayers))
|
||||||
|
for _, layer := range paths.settingsLayers {
|
||||||
|
parsed, readErr := readCopilotSettingsLayer(layer)
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, nil, readErr
|
||||||
|
}
|
||||||
|
settings = append(settings, parsed)
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
user = &copilotDocument{
|
user = createCopilotDocument(paths.userHookPath)
|
||||||
filePath: userPath,
|
|
||||||
root: map[string]any{},
|
|
||||||
hooks: copilotHooks{},
|
|
||||||
}
|
}
|
||||||
|
disabledBy := effectiveCopilotDisabledSource(settings)
|
||||||
|
if user.hooksDisabled {
|
||||||
|
disabledBy = fmt.Sprintf("GitHub Copilot user hooks at %s", paths.userHookPath)
|
||||||
}
|
}
|
||||||
return &copilotSources{user: user, legacy: legacy}, nil
|
preconditions := make([]copilotSourcePrecondition, 0, len(settings))
|
||||||
|
for _, item := range settings {
|
||||||
|
preconditions = append(preconditions, copilotSourcePrecondition{
|
||||||
|
filePath: item.layer.filePath,
|
||||||
|
label: item.layer.label,
|
||||||
|
snapshot: item.snapshot,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &copilotSources{
|
||||||
|
user: user, legacy: legacy, disabledBy: disabledBy,
|
||||||
|
settingsPreconditions: preconditions,
|
||||||
|
}, paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireCopilotHooksEnabled(sources *copilotSources) error {
|
||||||
|
if sources.disabledBy == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"GitHub Copilot hooks are disabled by %s; set disableAllHooks to false before installation",
|
||||||
|
sources.disabledBy,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareRenderedCopilotChange(
|
func prepareRenderedCopilotChange(
|
||||||
@@ -144,166 +222,41 @@ func prepareRenderedCopilotChange(
|
|||||||
if rendered == nil || !rendered.changed {
|
if rendered == nil || !rendered.changed {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return prepareSourceChange(
|
return prepareSnapshotSourceChange(
|
||||||
rendered.document.filePath,
|
rendered.document.filePath,
|
||||||
"GitHub Copilot hook source",
|
"GitHub Copilot hook source",
|
||||||
rendered.document.raw,
|
rendered.document.snapshot,
|
||||||
rendered.document.exists,
|
|
||||||
rendered.next,
|
rendered.next,
|
||||||
0600,
|
0600,
|
||||||
rendered.remove,
|
rendered.remove,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareCopilotInstallationChanges(
|
func writeCopilotChanges(
|
||||||
config InstallConfig,
|
changes []*fileChange,
|
||||||
|
label string,
|
||||||
|
rename renameFunc,
|
||||||
|
runtimeRoot string,
|
||||||
agentDirectory string,
|
agentDirectory string,
|
||||||
auditPath string,
|
preconditions []filePrecondition,
|
||||||
rendered []*copilotRenderedDocument,
|
) error {
|
||||||
) ([]*fileChange, error) {
|
hasChanges := false
|
||||||
baseURL := config.BaseURL
|
for _, change := range changes {
|
||||||
if baseURL == "" {
|
if change != nil {
|
||||||
baseURL = "https://api.elydora.com"
|
hasChanges = true
|
||||||
}
|
break
|
||||||
runtimeConfig, err := json.MarshalIndent(agentRuntimeConfig{
|
|
||||||
OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID,
|
|
||||||
BaseURL: baseURL, Token: config.Token, AgentName: copilotAgentKey,
|
|
||||||
}, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
runtimeConfig = append(runtimeConfig, '\n')
|
|
||||||
if len(runtimeConfig) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{auditPath, "Elydora audit runtime", []byte(buildHookScript(copilotAgentKey, config.AgentID)), 0700},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+len(rendered))
|
|
||||||
for _, item := range items {
|
|
||||||
if err := validateRuntimeFileTarget(item.path, item.label); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
for _, document := range rendered {
|
|
||||||
change, err := prepareRenderedCopilotChange(document)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
return changes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func activeCopilotContracts(
|
|
||||||
sources *copilotSources,
|
|
||||||
runtimeRoot string,
|
|
||||||
) []copilotRuntimeContract {
|
|
||||||
contracts := make([]copilotRuntimeContract, 0)
|
|
||||||
for _, document := range []*copilotDocument{sources.user, sources.legacy} {
|
|
||||||
if document == nil || document.disabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
contracts = append(contracts, copilotRuntimeContracts(document.hooks, runtimeRoot)...)
|
|
||||||
}
|
|
||||||
unique := map[string]copilotRuntimeContract{}
|
|
||||||
for _, contract := range contracts {
|
|
||||||
key := contract.agentID
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
key = strings.ToLower(key)
|
|
||||||
}
|
|
||||||
unique[key] = contract
|
|
||||||
}
|
|
||||||
result := make([]copilotRuntimeContract, 0, len(unique))
|
|
||||||
for _, contract := range unique {
|
|
||||||
result = append(result, contract)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func configuredCopilotPath(
|
|
||||||
sources *copilotSources,
|
|
||||||
runtimeRoot string,
|
|
||||||
) string {
|
|
||||||
if !sources.user.disabled && len(copilotRuntimeContracts(sources.user.hooks, runtimeRoot)) > 0 {
|
|
||||||
return sources.user.filePath
|
|
||||||
}
|
|
||||||
if sources.legacy != nil && !sources.legacy.disabled &&
|
|
||||||
len(copilotRuntimeContracts(sources.legacy.hooks, runtimeRoot)) > 0 {
|
|
||||||
return sources.legacy.filePath
|
|
||||||
}
|
|
||||||
return sources.user.filePath
|
|
||||||
}
|
|
||||||
|
|
||||||
func copilotPhysicalFileExists(path, label string) (bool, error) {
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("inspect %s at %s: %w", label, path, err)
|
|
||||||
}
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
||||||
return false, fmt.Errorf("%s path is not a physical file: %s", label, path)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func copilotRuntimeFilesExist(contracts []copilotRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json")
|
|
||||||
configExists, err := copilotPhysicalFileExists(configPath, "Elydora runtime config")
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
raw, exists, err := readOptionalFile(configPath, "Elydora runtime config")
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var value any
|
|
||||||
if err := json.Unmarshal(raw, &value); err != nil {
|
|
||||||
return false, fmt.Errorf("parse Elydora runtime config at %s: %w", configPath, err)
|
|
||||||
}
|
|
||||||
config, ok := value.(map[string]any)
|
|
||||||
if !ok || config == nil {
|
|
||||||
return false, fmt.Errorf("Elydora runtime config at %s must contain a JSON object", configPath)
|
|
||||||
}
|
|
||||||
agentID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != copilotAgentKey ||
|
|
||||||
!sameCopilotAgentID(agentID, contract.agentID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
guardExists, err := copilotPhysicalFileExists(contract.guardPath, "Elydora guard runtime")
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
auditExists, err := copilotPhysicalFileExists(contract.auditPath, "Elydora audit runtime")
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if guardExists && auditExists {
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
if !hasChanges {
|
||||||
|
return writeChanges(changes, label, rename, preconditions...)
|
||||||
|
}
|
||||||
|
if agentDirectory != "" {
|
||||||
|
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return writeChanges(changes, label, rename, preconditions...)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOfficialCopilot1071LoadsAllManagedHooks(t *testing.T) {
|
||||||
|
entry := os.Getenv("ELYDORA_COPILOT_ENTRY")
|
||||||
|
runtimeEntry := os.Getenv("ELYDORA_COPILOT_RUNTIME_ENTRY")
|
||||||
|
nodePath, nodeErr := exec.LookPath("node")
|
||||||
|
if entry == "" || runtimeEntry == "" || nodeErr != nil {
|
||||||
|
t.Skip(
|
||||||
|
"set ELYDORA_COPILOT_ENTRY and ELYDORA_COPILOT_RUNTIME_ENTRY to official package files",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
|
installCopilotFixture(t, fixture)
|
||||||
|
environment := append(os.Environ(),
|
||||||
|
"HOME="+fixture.homeDir,
|
||||||
|
"USERPROFILE="+fixture.homeDir,
|
||||||
|
"COPILOT_HOME="+fixture.copilotHome,
|
||||||
|
)
|
||||||
|
version := exec.Command(nodePath, entry, "--version") // #nosec G204 -- test paths are explicit environment inputs.
|
||||||
|
version.Dir = fixture.projectDir
|
||||||
|
version.Env = environment
|
||||||
|
versionOutput, err := version.CombinedOutput()
|
||||||
|
if err != nil || !strings.Contains(string(versionOutput), "GitHub Copilot CLI 1.0.71.") {
|
||||||
|
t.Fatalf("official Copilot version = %q, %v", versionOutput, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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-go-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);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
load := exec.Command( // #nosec G204 -- nodePath is resolved through exec.LookPath.
|
||||||
|
nodePath,
|
||||||
|
"--input-type=module",
|
||||||
|
"--eval",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
load.Dir = fixture.projectDir
|
||||||
|
load.Env = append(environment,
|
||||||
|
"ELYDORA_COPILOT_RUNTIME_ENTRY="+runtimeEntry,
|
||||||
|
"ELYDORA_HOOKS="+filepath.Dir(fixture.configPath),
|
||||||
|
"ELYDORA_PROJECT="+fixture.projectDir,
|
||||||
|
)
|
||||||
|
loaded, err := load.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load official Copilot hooks: %s: %v", loaded, err)
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(loaded, &result); err != nil {
|
||||||
|
t.Fatalf("decode official Copilot loader output %q: %v", loaded, err)
|
||||||
|
}
|
||||||
|
loadResult := requireCopilotObject(t, result["load"])
|
||||||
|
if loadResult["hookCount"] != float64(3) ||
|
||||||
|
len(requireCopilotArray(t, loadResult["errors"])) != 0 ||
|
||||||
|
len(requireCopilotArray(t, loadResult["warnings"])) != 0 {
|
||||||
|
t.Fatalf("official Copilot load result = %#v", loadResult)
|
||||||
|
}
|
||||||
|
snapshot := requireCopilotObject(t, result["snapshot"])
|
||||||
|
hooks := requireCopilotArray(t, snapshot["hooks"])
|
||||||
|
events := make([]string, 0, len(hooks))
|
||||||
|
for _, value := range hooks {
|
||||||
|
hook := requireCopilotObject(t, value)
|
||||||
|
events = append(events, stringValue(hook["eventName"]))
|
||||||
|
if filepath.Base(stringValue(hook["source"])) != copilotConfigFile {
|
||||||
|
t.Fatalf("official Copilot hook source = %#v", hook["source"])
|
||||||
|
}
|
||||||
|
var spec map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(stringValue(hook["specJson"])), &spec); err != nil {
|
||||||
|
t.Fatalf("decode official hook spec: %v", err)
|
||||||
|
}
|
||||||
|
config := requireCopilotObject(t, spec["config"])
|
||||||
|
if config["type"] != "command" || config["timeoutSec"] != copilotHookTimeout ||
|
||||||
|
!strings.Contains(strings.ToLower(stringValue(config["bash"])), "node") ||
|
||||||
|
!strings.HasPrefix(stringValue(config["powershell"]), "& ") {
|
||||||
|
t.Fatalf("official Copilot hook spec = %#v", spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(events)
|
||||||
|
want := []string{"postToolUse", "postToolUseFailure", "preToolUse"}
|
||||||
|
if strings.Join(events, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("official Copilot events = %#v, want %#v", events, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,42 +64,46 @@ func TestCopilotInstallRejectsInvalidProjectSourceBeforeWrites(t *testing.T) {
|
|||||||
assertCopilotRuntimeAbsent(t, fixture)
|
assertCopilotRuntimeAbsent(t, fixture)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCopilotInstallRejectsMissingOrUnmanagedGuardBeforeWrites(t *testing.T) {
|
func TestCopilotInstallGeneratesManagedGuardAndRejectsUnmanagedPath(t *testing.T) {
|
||||||
t.Run("missing", func(t *testing.T) {
|
|
||||||
fixture := prepareCopilotFixture(t, copilotFixtureOptions{skipGuard: true})
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err == nil {
|
|
||||||
t.Fatal("install accepted a missing guard runtime")
|
|
||||||
}
|
|
||||||
assertCopilotRuntimeAbsent(t, fixture)
|
|
||||||
})
|
|
||||||
t.Run("unmanaged path", func(t *testing.T) {
|
|
||||||
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
unmanaged := filepath.Join(fixture.homeDir, "unmanaged-guard.js")
|
installCopilotFixture(t, fixture)
|
||||||
if err := os.WriteFile(unmanaged, []byte("process.exit(2);\n"), 0700); err != nil {
|
expected := generateGuardScript(copilotAgentKey, copilotTestAgentID, "", false, "")
|
||||||
t.Fatalf("write unmanaged guard: %v", err)
|
actual, err := os.ReadFile(fixture.guardPath)
|
||||||
|
if err != nil || string(actual) != expected {
|
||||||
|
t.Fatalf("generated guard = %q, %v", actual, err)
|
||||||
}
|
}
|
||||||
fixture.config.GuardScriptPath = unmanaged
|
|
||||||
|
fixture = prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
|
fixture.config.GuardScriptPath = filepath.Join(fixture.homeDir, "unmanaged-guard.js")
|
||||||
if err := fixture.plugin.Install(fixture.config); err == nil {
|
if err := fixture.plugin.Install(fixture.config); err == nil {
|
||||||
t.Fatal("install accepted an unmanaged guard runtime")
|
t.Fatal("install accepted an unmanaged guard runtime")
|
||||||
}
|
}
|
||||||
assertCopilotRuntimeAbsent(t, fixture)
|
assertCopilotRuntimeAbsent(t, fixture)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCopilotInstallRejectsSymlinkFiles(t *testing.T) {
|
func TestCopilotInstallRejectsSymlinkFiles(t *testing.T) {
|
||||||
t.Run("guard runtime", func(t *testing.T) {
|
t.Run("guard runtime", func(t *testing.T) {
|
||||||
fixture := prepareCopilotFixture(t, copilotFixtureOptions{skipGuard: true})
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
target := filepath.Join(fixture.homeDir, "guard-target.js")
|
target := filepath.Join(fixture.homeDir, "guard-target.js")
|
||||||
if err := os.WriteFile(target, []byte("process.exit(2);\n"), 0700); err != nil {
|
if err := os.WriteFile(target, []byte("process.exit(2);\n"), 0700); err != nil {
|
||||||
t.Fatalf("write guard target: %v", err)
|
t.Fatalf("write guard target: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
|
||||||
|
t.Fatalf("create agent runtime directory: %v", err)
|
||||||
|
}
|
||||||
if err := os.Symlink(target, fixture.guardPath); err != nil {
|
if err := os.Symlink(target, fixture.guardPath); err != nil {
|
||||||
t.Skipf("create guard symlink: %v", err)
|
t.Skipf("create guard symlink: %v", err)
|
||||||
}
|
}
|
||||||
if err := fixture.plugin.Install(fixture.config); err == nil {
|
if err := fixture.plugin.Install(fixture.config); err == nil {
|
||||||
t.Fatal("install accepted a symlink guard runtime")
|
t.Fatal("install accepted a symlink guard runtime")
|
||||||
}
|
}
|
||||||
assertCopilotRuntimeAbsent(t, fixture)
|
for _, path := range []string{
|
||||||
|
fixture.runtimeConfig, fixture.privateKey, fixture.hookPath,
|
||||||
|
} {
|
||||||
|
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("runtime file exists at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
t.Run("user source", func(t *testing.T) {
|
t.Run("user source", func(t *testing.T) {
|
||||||
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
@@ -248,7 +252,9 @@ func TestCopilotInstallDetectsConcurrentSourceChangeAndRollsBack(t *testing.T) {
|
|||||||
|
|
||||||
func assertCopilotRuntimeAbsent(t *testing.T, fixture *copilotFixture) {
|
func assertCopilotRuntimeAbsent(t *testing.T, fixture *copilotFixture) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, path := range []string{fixture.runtimeConfig, fixture.privateKey, fixture.hookPath} {
|
for _, path := range []string{
|
||||||
|
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath,
|
||||||
|
} {
|
||||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||||
t.Fatalf("runtime file exists at %s: %v", path, err)
|
t.Fatalf("runtime file exists at %s: %v", path, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const copilotRegexValidationTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
var copilotSupportedEvents = stringSet(
|
||||||
|
"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",
|
||||||
|
)
|
||||||
|
|
||||||
|
const copilotRegexValidator = `import fs from "node:fs";
|
||||||
|
const entries = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||||
|
for (const entry of entries) {
|
||||||
|
try {
|
||||||
|
new RegExp("^(?:" + entry.pattern + ")$");
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
process.stderr.write(entry.label + ": " + message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
type copilotMatcherEntry struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func copilotFieldLabel(label, field string) string {
|
||||||
|
return fmt.Sprintf(`%s field %q`, label, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireCopilotString(value any, label string) (string, error) {
|
||||||
|
text, ok := value.(string)
|
||||||
|
if !ok || text == "" {
|
||||||
|
return "", fmt.Errorf("%s must be a non-empty string", label)
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotOptionalString(
|
||||||
|
handler map[string]any,
|
||||||
|
field string,
|
||||||
|
label string,
|
||||||
|
) error {
|
||||||
|
value, exists := handler[field]
|
||||||
|
if exists {
|
||||||
|
if _, ok := value.(string); !ok {
|
||||||
|
return fmt.Errorf("%s must be a string", copilotFieldLabel(label, field))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotTimeouts(handler map[string]any, label string) error {
|
||||||
|
for _, field := range []string{"timeout", "timeoutSec"} {
|
||||||
|
value, exists := handler[field]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
number, ok := value.(float64)
|
||||||
|
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s must be a positive number",
|
||||||
|
copilotFieldLabel(label, field),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotMatcher(
|
||||||
|
handler map[string]any,
|
||||||
|
label string,
|
||||||
|
) error {
|
||||||
|
value, exists := handler["matcher"]
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := requireCopilotString(value, copilotFieldLabel(label, "matcher"))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotStringMap(value any, label string) error {
|
||||||
|
object, ok := value.(map[string]any)
|
||||||
|
if !ok || object == nil {
|
||||||
|
return fmt.Errorf("%s must be an object", label)
|
||||||
|
}
|
||||||
|
for key, item := range object {
|
||||||
|
if _, err := requireCopilotString(item, label+"."+key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotCommand(
|
||||||
|
handler map[string]any,
|
||||||
|
label string,
|
||||||
|
) error {
|
||||||
|
hasCommand := false
|
||||||
|
for _, field := range []string{"bash", "powershell", "command"} {
|
||||||
|
if _, exists := handler[field]; exists {
|
||||||
|
hasCommand = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasCommand {
|
||||||
|
return fmt.Errorf("%s must define bash, powershell, or command", label)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"bash", "powershell", "command", "cwd"} {
|
||||||
|
if err := validateCopilotOptionalString(handler, field, label); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value, exists := handler["env"]; exists {
|
||||||
|
if err := validateCopilotStringMap(value, copilotFieldLabel(label, "env")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateCopilotTimeouts(handler, label); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateCopilotMatcher(handler, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copilotLoopbackHost(hostname string) bool {
|
||||||
|
return hostname == "localhost" || hostname == "::1" ||
|
||||||
|
strings.HasPrefix(hostname, "127.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotHTTP(
|
||||||
|
handler map[string]any,
|
||||||
|
label string,
|
||||||
|
) error {
|
||||||
|
rawURL, err := requireCopilotString(
|
||||||
|
handler["url"],
|
||||||
|
copilotFieldLabel(label, "url"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed.Host == "" || parsed.Hostname() == "" {
|
||||||
|
return fmt.Errorf("%s is invalid", copilotFieldLabel(label, "url"))
|
||||||
|
}
|
||||||
|
localhostAllowed := os.Getenv("COPILOT_HOOK_ALLOW_LOCALHOST") == "1" &&
|
||||||
|
parsed.Scheme == "http" && copilotLoopbackHost(parsed.Hostname())
|
||||||
|
if parsed.Scheme != "https" && !localhostAllowed {
|
||||||
|
return fmt.Errorf("%s must use HTTPS", copilotFieldLabel(label, "url"))
|
||||||
|
}
|
||||||
|
if value, exists := handler["headers"]; exists {
|
||||||
|
if err := validateCopilotStringMap(value, copilotFieldLabel(label, "headers")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value, exists := handler["allowedEnvVars"]; exists {
|
||||||
|
items, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s must be an array of strings",
|
||||||
|
copilotFieldLabel(label, "allowedEnvVars"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if text, ok := item.(string); !ok || text == "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s must be an array of strings",
|
||||||
|
copilotFieldLabel(label, "allowedEnvVars"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateCopilotTimeouts(handler, label); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateCopilotMatcher(handler, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotPrompt(
|
||||||
|
handler map[string]any,
|
||||||
|
event string,
|
||||||
|
label string,
|
||||||
|
) error {
|
||||||
|
if event != "sessionStart" && event != "SessionStart" {
|
||||||
|
return fmt.Errorf("%s prompt hooks are supported only for sessionStart", label)
|
||||||
|
}
|
||||||
|
_, err := requireCopilotString(handler["prompt"], copilotFieldLabel(label, "prompt"))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotHandler(
|
||||||
|
handler map[string]any,
|
||||||
|
event string,
|
||||||
|
label string,
|
||||||
|
) error {
|
||||||
|
handlerType := "command"
|
||||||
|
if value, exists := handler["type"]; exists {
|
||||||
|
var ok bool
|
||||||
|
handlerType, ok = value.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s is unsupported", copilotFieldLabel(label, "type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch handlerType {
|
||||||
|
case "command":
|
||||||
|
return validateCopilotCommand(handler, label)
|
||||||
|
case "http":
|
||||||
|
return validateCopilotHTTP(handler, label)
|
||||||
|
case "prompt":
|
||||||
|
return validateCopilotPrompt(handler, event, label)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s is unsupported", copilotFieldLabel(label, "type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotHooks(value any, label string) (copilotHooks, error) {
|
||||||
|
object, ok := value.(map[string]any)
|
||||||
|
if !ok || object == nil {
|
||||||
|
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
|
||||||
|
}
|
||||||
|
hooks := make(copilotHooks, len(object))
|
||||||
|
for event, candidate := range object {
|
||||||
|
if _, supported := copilotSupportedEvents[event]; !supported {
|
||||||
|
return nil, fmt.Errorf("%s hook event %q is unsupported", label, event)
|
||||||
|
}
|
||||||
|
values, ok := candidate.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event)
|
||||||
|
}
|
||||||
|
handlers := make([]map[string]any, 0, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
handler, ok := value.(map[string]any)
|
||||||
|
itemLabel := fmt.Sprintf("%s handler hooks.%s[%d]", label, event, index)
|
||||||
|
if !ok || handler == nil {
|
||||||
|
return nil, fmt.Errorf("%s must be an object", itemLabel)
|
||||||
|
}
|
||||||
|
if err := validateCopilotHandler(handler, event, itemLabel); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
handlers = append(handlers, cloneCopilotObject(handler))
|
||||||
|
}
|
||||||
|
hooks[event] = handlers
|
||||||
|
}
|
||||||
|
return hooks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copilotMatcherEntries(hooks copilotHooks) []copilotMatcherEntry {
|
||||||
|
entries := make([]copilotMatcherEntry, 0)
|
||||||
|
wildcardEvents := stringSet(
|
||||||
|
"preToolUse", "PreToolUse", "permissionRequest", "PermissionRequest",
|
||||||
|
)
|
||||||
|
for event, handlers := range hooks {
|
||||||
|
for index, handler := range handlers {
|
||||||
|
matcher, ok := handler["matcher"].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, wildcardEvent := wildcardEvents[event]; wildcardEvent &&
|
||||||
|
(matcher == "*" || matcher == "**") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, copilotMatcherEntry{
|
||||||
|
Label: fmt.Sprintf("GitHub Copilot hooks.%s[%d] matcher", event, index),
|
||||||
|
Pattern: matcher,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotJavaScriptMatchers(
|
||||||
|
sources []copilotHooks,
|
||||||
|
nodePath string,
|
||||||
|
) error {
|
||||||
|
entries := make([]copilotMatcherEntry, 0)
|
||||||
|
for _, hooks := range sources {
|
||||||
|
entries = append(entries, copilotMatcherEntries(hooks)...)
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
input, err := json.Marshal(entries)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode GitHub Copilot matchers: %w", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), copilotRegexValidationTimeout)
|
||||||
|
defer cancel()
|
||||||
|
command := exec.CommandContext( // #nosec G204 -- nodePath is resolved through exec.LookPath.
|
||||||
|
ctx,
|
||||||
|
nodePath,
|
||||||
|
"--input-type=module",
|
||||||
|
"--eval",
|
||||||
|
copilotRegexValidator,
|
||||||
|
)
|
||||||
|
command.Stdin = bytes.NewReader(input)
|
||||||
|
output, runErr := command.CombinedOutput()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"GitHub Copilot matcher validation timed out after %s",
|
||||||
|
copilotRegexValidationTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
message := strings.TrimSpace(string(output))
|
||||||
|
if message == "" {
|
||||||
|
message = runErr.Error()
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"GitHub Copilot matcher must be a valid JavaScript regular expression: %s",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopilotSettingsPrecedenceAllowsLaterFalseAndPreservesJSONC(t *testing.T) {
|
||||||
|
userSource := "{\n // user policy\n \"disableAllHooks\": true,\n}\n"
|
||||||
|
claudeSource := "{\n \"disableAllHooks\": true,\n}\n"
|
||||||
|
repositorySource := "{\n \"disableAllHooks\": false,\n}\n"
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
|
||||||
|
userSettingsRaw: copilotString(userSource),
|
||||||
|
claudeLocalSettingsRaw: copilotString(claudeSource),
|
||||||
|
repositorySettingsRaw: copilotString(repositorySource),
|
||||||
|
})
|
||||||
|
|
||||||
|
installCopilotFixture(t, fixture)
|
||||||
|
|
||||||
|
assertCopilotFileEquals(t, fixture.userSettings, userSource)
|
||||||
|
assertCopilotFileEquals(t, fixture.claudeLocalSettings, claudeSource)
|
||||||
|
assertCopilotFileEquals(t, fixture.repositorySettings, repositorySource)
|
||||||
|
status, err := fixture.plugin.Status()
|
||||||
|
if err != nil || !status.Installed {
|
||||||
|
t.Fatalf("Copilot status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopilotEffectiveDisabledSettingsRejectBeforeWrites(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
options copilotFixtureOptions
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "repository",
|
||||||
|
options: copilotFixtureOptions{
|
||||||
|
repositorySettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
|
||||||
|
},
|
||||||
|
want: "repository settings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "local repository",
|
||||||
|
options: copilotFixtureOptions{
|
||||||
|
claudeLocalSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
|
||||||
|
localSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
|
||||||
|
},
|
||||||
|
want: "local settings",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, testCase.options)
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||||
|
t.Fatalf("install error = %v, want %q", err, testCase.want)
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("hook source exists after rejection: %v", err)
|
||||||
|
}
|
||||||
|
assertCopilotRuntimeAbsent(t, fixture)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopilotInvalidSettingsRemainUnchanged(t *testing.T) {
|
||||||
|
for name, source := range map[string]string{
|
||||||
|
"malformed": "{ malformed",
|
||||||
|
"duplicate": `{"disableAllHooks":true,"disableAllHooks":false}`,
|
||||||
|
"invalid boolean": `{"disableAllHooks":"yes"}`,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
|
||||||
|
userSettingsRaw: copilotString(source),
|
||||||
|
})
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err == nil {
|
||||||
|
t.Fatal("install accepted invalid GitHub Copilot settings")
|
||||||
|
}
|
||||||
|
assertCopilotFileEquals(t, fixture.userSettings, source)
|
||||||
|
assertCopilotRuntimeAbsent(t, fixture)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopilotRejectsCompleteInvalidHookSchemaBeforeWrites(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"duplicate key": `{"version":1,"version":1,"hooks":{}}`,
|
||||||
|
"boolean version": `{"version":true,"hooks":{}}`,
|
||||||
|
"unknown event": `{"version":1,"hooks":{"unknownEvent":[]}}`,
|
||||||
|
"empty command": `{"version":1,"hooks":{"preToolUse":[{}]}}`,
|
||||||
|
"invalid command": `{"version":1,"hooks":{"preToolUse":[{"command":1}]}}`,
|
||||||
|
"zero timeout": `{"version":1,"hooks":{"preToolUse":[{"command":"x","timeoutSec":0}]}}`,
|
||||||
|
"prompt event": `{"version":1,"hooks":{"postToolUse":[{"type":"prompt","prompt":"x"}]}}`,
|
||||||
|
"insecure HTTP": `{"version":1,"hooks":{"postToolUse":[{"type":"http","url":"http://example.com"}]}}`,
|
||||||
|
"invalid matcher": `{"version":1,"hooks":{"preToolUse":[{"command":"x","matcher":"["}]}}`,
|
||||||
|
}
|
||||||
|
for name, source := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
|
||||||
|
userRaw: copilotString(source),
|
||||||
|
})
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err == nil {
|
||||||
|
t.Fatal("install accepted an invalid GitHub Copilot hook document")
|
||||||
|
}
|
||||||
|
assertCopilotFileEquals(t, fixture.configPath, source)
|
||||||
|
assertCopilotRuntimeAbsent(t, fixture)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopilotMatchersUseJavaScriptRegularExpressionSyntax(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
|
||||||
|
userRaw: copilotJSON(map[string]any{
|
||||||
|
"version": float64(1),
|
||||||
|
"hooks": map[string]any{
|
||||||
|
"preToolUse": []any{map[string]any{
|
||||||
|
"type": "command", "command": "user-pre", "matcher": "(?<tool>shell)",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
installCopilotFixture(t, fixture)
|
||||||
|
|
||||||
|
settings := readCopilotObject(t, fixture.configPath)
|
||||||
|
hooks := requireCopilotObject(t, settings["hooks"])
|
||||||
|
pre := requireCopilotArray(t, hooks["preToolUse"])
|
||||||
|
if requireCopilotObject(t, pre[0])["matcher"] != "(?<tool>shell)" {
|
||||||
|
t.Fatalf("preserved matcher = %#v", pre[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopilotUninstallRemainsAvailableWithoutNodeMatcherValidation(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
|
installCopilotFixture(t, fixture)
|
||||||
|
settings := readCopilotObject(t, fixture.configPath)
|
||||||
|
hooks := requireCopilotObject(t, settings["hooks"])
|
||||||
|
pre := requireCopilotArray(t, hooks["preToolUse"])
|
||||||
|
pre = append(pre, map[string]any{
|
||||||
|
"type": "command", "command": "user-pre", "matcher": "[",
|
||||||
|
})
|
||||||
|
hooks["preToolUse"] = pre
|
||||||
|
writeCopilotObject(t, fixture.configPath, settings)
|
||||||
|
t.Setenv("PATH", "")
|
||||||
|
|
||||||
|
if err := fixture.plugin.Uninstall(copilotTestAgentID); err != nil {
|
||||||
|
t.Fatalf("uninstall GitHub Copilot hooks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := readCopilotObject(t, fixture.configPath)
|
||||||
|
remainingHooks := requireCopilotObject(t, remaining["hooks"])
|
||||||
|
if len(requireCopilotArray(t, remainingHooks["preToolUse"])) != 1 {
|
||||||
|
t.Fatalf("remaining hooks = %#v", remainingHooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readCopilotRuntimeConfig(path string) (map[string]any, bool, error) {
|
||||||
|
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
||||||
|
if err != nil || snapshot == nil {
|
||||||
|
return nil, snapshot != nil, err
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
||||||
|
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
||||||
|
return config, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireCopilotRuntimeString(
|
||||||
|
config map[string]any,
|
||||||
|
field string,
|
||||||
|
configPath string,
|
||||||
|
) (string, error) {
|
||||||
|
value, ok := config[field].(string)
|
||||||
|
if !ok || strings.TrimSpace(value) == "" {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"Elydora runtime config %s is invalid: %s",
|
||||||
|
field,
|
||||||
|
configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotRuntimeConfig(
|
||||||
|
config map[string]any,
|
||||||
|
expectedAgentID string,
|
||||||
|
configPath string,
|
||||||
|
) error {
|
||||||
|
supported := stringSet(
|
||||||
|
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
||||||
|
)
|
||||||
|
fields := make([]string, 0, len(config))
|
||||||
|
for field := range config {
|
||||||
|
fields = append(fields, field)
|
||||||
|
}
|
||||||
|
sort.Strings(fields)
|
||||||
|
for _, field := range fields {
|
||||||
|
if _, ok := supported[field]; !ok {
|
||||||
|
return fmt.Errorf(
|
||||||
|
`Elydora runtime config has unsupported field %q: %s`,
|
||||||
|
field,
|
||||||
|
configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := requireCopilotRuntimeString(config, "org_id", configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := requireCopilotRuntimeString(config, "kid", configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
agentID, err := requireCopilotRuntimeString(config, "agent_id", configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !sameCopilotAgentID(agentID, expectedAgentID) ||
|
||||||
|
config["agent_name"] != copilotAgentKey {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Elydora runtime identity does not match Copilot hooks: %s",
|
||||||
|
configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, exists := config["token"]; exists {
|
||||||
|
if _, err := requireCopilotRuntimeString(config, "token", configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseURL, err := requireCopilotRuntimeString(config, "base_url", configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateManagedBaseURL(baseURL); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Elydora runtime config base URL is invalid at %s: %w",
|
||||||
|
configPath,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCopilotRuntimeIdentity(agentDirectory, agentID string) error {
|
||||||
|
runtimeRoot := filepath.Dir(agentDirectory)
|
||||||
|
rootExists, err := managedPhysicalDirectoryExists(
|
||||||
|
runtimeRoot,
|
||||||
|
"Elydora runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !rootExists {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
directoryExists, err := managedPhysicalDirectoryExists(
|
||||||
|
agentDirectory,
|
||||||
|
"Elydora agent runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !directoryExists {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(agentDirectory, "config.json")
|
||||||
|
config, configExists, err := readCopilotRuntimeConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
artifactExists := false
|
||||||
|
for _, item := range []struct {
|
||||||
|
path string
|
||||||
|
label string
|
||||||
|
limit int64
|
||||||
|
}{
|
||||||
|
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
||||||
|
{filepath.Join(agentDirectory, copilotGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
||||||
|
{filepath.Join(agentDirectory, copilotAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
||||||
|
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
||||||
|
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
||||||
|
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
||||||
|
} {
|
||||||
|
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
||||||
|
if inspectErr != nil {
|
||||||
|
return inspectErr
|
||||||
|
}
|
||||||
|
artifactExists = artifactExists || exists
|
||||||
|
}
|
||||||
|
if !configExists {
|
||||||
|
if artifactExists {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Elydora runtime identity cannot be verified without config.json: %s",
|
||||||
|
agentDirectory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
configuredID, ok := config["agent_id"].(string)
|
||||||
|
if !ok || config["agent_name"] != copilotAgentKey ||
|
||||||
|
!sameCopilotAgentID(configuredID, agentID) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"Elydora runtime config identity does not match Copilot agent %s: %s",
|
||||||
|
agentID,
|
||||||
|
configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCopilotContractPaths(contract copilotRuntimeContract) (bool, error) {
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
agentDirectory := filepath.Dir(contract.guardPath)
|
||||||
|
return sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) &&
|
||||||
|
sameCopilotPath(
|
||||||
|
contract.guardPath,
|
||||||
|
filepath.Join(agentDirectory, copilotGuardScript),
|
||||||
|
) &&
|
||||||
|
sameCopilotPath(
|
||||||
|
contract.auditPath,
|
||||||
|
filepath.Join(agentDirectory, copilotAuditScript),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copilotRuntimeContractExists(contract copilotRuntimeContract) (bool, error) {
|
||||||
|
validPaths, err := validCopilotContractPaths(contract)
|
||||||
|
if err != nil || !validPaths {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
agentDirectory := filepath.Dir(contract.guardPath)
|
||||||
|
rootExists, err := managedPhysicalDirectoryExists(
|
||||||
|
runtimeRoot,
|
||||||
|
"Elydora runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !rootExists {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
directoryExists, err := managedPhysicalDirectoryExists(
|
||||||
|
agentDirectory,
|
||||||
|
"Elydora agent runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !directoryExists {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(agentDirectory, "config.json")
|
||||||
|
keyPath := filepath.Join(agentDirectory, "private.key")
|
||||||
|
config, configExists, err := readCopilotRuntimeConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
guard, err := readManagedFile(
|
||||||
|
contract.guardPath,
|
||||||
|
"Elydora guard runtime",
|
||||||
|
maxManagedSourceBytes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
audit, err := readManagedFile(
|
||||||
|
contract.auditPath,
|
||||||
|
"Elydora audit runtime",
|
||||||
|
maxManagedSourceBytes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !configExists || key == nil || guard == nil || audit == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := validateCopilotRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
||||||
|
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
|
||||||
|
}
|
||||||
|
expectedGuard := []byte(generateGuardScript(
|
||||||
|
copilotAgentKey,
|
||||||
|
contract.agentID,
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
expectedAudit := []byte(buildHookScriptWithOutput(
|
||||||
|
copilotAgentKey,
|
||||||
|
contract.agentID,
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
return bytes.Equal(guard.contents, expectedGuard) &&
|
||||||
|
bytes.Equal(audit.contents, expectedAudit), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copilotRuntimeFilesExist(contracts []copilotRuntimeContract) (bool, error) {
|
||||||
|
for _, contract := range contracts {
|
||||||
|
exists, err := copilotRuntimeContractExists(contract)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ func TestCopilotRegistryUsesNativeUserHooks(t *testing.T) {
|
|||||||
if got := SupportedAgents[copilotAgentKey]; !reflect.DeepEqual(got, want) {
|
if got := SupportedAgents[copilotAgentKey]; !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("Copilot registry = %#v, want %#v", got, want)
|
t.Fatalf("Copilot registry = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
|
plugin := NewPlugin(copilotAgentKey)
|
||||||
|
manager, ok := plugin.(GuardRuntimeManager)
|
||||||
|
if !ok || !manager.ManagesGuardRuntime() {
|
||||||
|
t.Fatal("Copilot must own its generated guard transactionally")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testing.T) {
|
func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testing.T) {
|
||||||
@@ -48,7 +53,8 @@ func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testin
|
|||||||
}
|
}
|
||||||
preHooks := requireCopilotArray(t, hooks["preToolUse"])
|
preHooks := requireCopilotArray(t, hooks["preToolUse"])
|
||||||
postHooks := requireCopilotArray(t, hooks["postToolUse"])
|
postHooks := requireCopilotArray(t, hooks["postToolUse"])
|
||||||
if len(preHooks) != 2 || len(postHooks) != 1 {
|
failureHooks := requireCopilotArray(t, hooks["postToolUseFailure"])
|
||||||
|
if len(preHooks) != 2 || len(postHooks) != 1 || len(failureHooks) != 1 {
|
||||||
t.Fatalf("Copilot hooks = %#v", hooks)
|
t.Fatalf("Copilot hooks = %#v", hooks)
|
||||||
}
|
}
|
||||||
if requireCopilotObject(t, preHooks[0])["command"] != "user-pre" {
|
if requireCopilotObject(t, preHooks[0])["command"] != "user-pre" {
|
||||||
@@ -60,6 +66,9 @@ func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testin
|
|||||||
assertNativeCopilotHandler(t, managedCopilotTestHandler(
|
assertNativeCopilotHandler(t, managedCopilotTestHandler(
|
||||||
t, settings, "postToolUse", copilotAuditScript,
|
t, settings, "postToolUse", copilotAuditScript,
|
||||||
))
|
))
|
||||||
|
assertNativeCopilotHandler(t, managedCopilotTestHandler(
|
||||||
|
t, settings, "postToolUseFailure", copilotAuditScript,
|
||||||
|
))
|
||||||
legacy := readCopilotObject(t, fixture.legacyPath)
|
legacy := readCopilotObject(t, fixture.legacyPath)
|
||||||
legacyHooks := requireCopilotObject(t, legacy["hooks"])
|
legacyHooks := requireCopilotObject(t, legacy["hooks"])
|
||||||
if len(legacyHooks) != 1 || legacyHooks["notification"] == nil {
|
if len(legacyHooks) != 1 || legacyHooks["notification"] == nil {
|
||||||
@@ -98,6 +107,13 @@ func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) {
|
|||||||
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
installCopilotFixture(t, fixture)
|
installCopilotFixture(t, fixture)
|
||||||
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
||||||
|
guardScript := "const fs = require('node:fs');\n" +
|
||||||
|
"fs.readFileSync(0);\n" +
|
||||||
|
"process.stderr.write('Agent is frozen by Elydora.');\n" +
|
||||||
|
"process.exit(2);\n"
|
||||||
|
if err := os.WriteFile(fixture.guardPath, []byte(guardScript), 0700); err != nil {
|
||||||
|
t.Fatalf("write blocking guard runtime: %v", err)
|
||||||
|
}
|
||||||
captureScript := "const fs = require('node:fs');\n" +
|
captureScript := "const fs = require('node:fs');\n" +
|
||||||
"fs.writeFileSync(process.env.ELYDORA_CAPTURE, fs.readFileSync(0));\n"
|
"fs.writeFileSync(process.env.ELYDORA_CAPTURE, fs.readFileSync(0));\n"
|
||||||
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
|
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
|
||||||
@@ -110,7 +126,7 @@ func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) {
|
|||||||
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") {
|
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") {
|
||||||
t.Fatalf("guard result = %#v", guardResult)
|
t.Fatalf("guard result = %#v", guardResult)
|
||||||
}
|
}
|
||||||
postPayload := `{"sessionId":"session-1","timestamp":2,"cwd":"project","toolName":"powershell","toolArgs":{"command":"Get-ChildItem"},"toolResult":{"output":"ok"}}` + "\n"
|
postPayload := `{"sessionId":"session-1","timestamp":2,"cwd":"project","toolName":"powershell","toolArgs":{"command":"Get-ChildItem"},"toolResult":{"resultType":"success","textResultForLlm":"ok"}}` + "\n"
|
||||||
audit := managedCopilotTestHandler(t, settings, "postToolUse", copilotAuditScript)
|
audit := managedCopilotTestHandler(t, settings, "postToolUse", copilotAuditScript)
|
||||||
auditResult := runCopilotHandler(t, audit, postPayload, "ELYDORA_CAPTURE="+capturePath)
|
auditResult := runCopilotHandler(t, audit, postPayload, "ELYDORA_CAPTURE="+capturePath)
|
||||||
if auditResult.exitCode != 0 {
|
if auditResult.exitCode != 0 {
|
||||||
@@ -120,6 +136,16 @@ func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) {
|
|||||||
if err != nil || string(captured) != postPayload {
|
if err != nil || string(captured) != postPayload {
|
||||||
t.Fatalf("captured payload = %q, %v", captured, err)
|
t.Fatalf("captured payload = %q, %v", captured, err)
|
||||||
}
|
}
|
||||||
|
failurePayload := `{"sessionId":"session-1","timestamp":3,"cwd":"project","toolName":"powershell","toolArgs":{"command":"Get-ChildItem"},"error":"command failed"}` + "\n"
|
||||||
|
failure := managedCopilotTestHandler(t, settings, "postToolUseFailure", copilotAuditScript)
|
||||||
|
failureResult := runCopilotHandler(t, failure, failurePayload, "ELYDORA_CAPTURE="+capturePath)
|
||||||
|
if failureResult.exitCode != 0 {
|
||||||
|
t.Fatalf("failure audit result = %#v", failureResult)
|
||||||
|
}
|
||||||
|
captured, err = os.ReadFile(capturePath)
|
||||||
|
if err != nil || string(captured) != failurePayload {
|
||||||
|
t.Fatalf("captured failure payload = %q, %v", captured, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCopilotEmptyHomeOverrideUsesOfficialDefault(t *testing.T) {
|
func TestCopilotEmptyHomeOverrideUsesOfficialDefault(t *testing.T) {
|
||||||
@@ -146,7 +172,7 @@ func TestCopilotStatusRequiresEnabledPairIdentityAndRuntimeFiles(t *testing.T) {
|
|||||||
|
|
||||||
settings := readCopilotObject(t, fixture.configPath)
|
settings := readCopilotObject(t, fixture.configPath)
|
||||||
hooks := requireCopilotObject(t, settings["hooks"])
|
hooks := requireCopilotObject(t, settings["hooks"])
|
||||||
delete(hooks, "postToolUse")
|
delete(hooks, "postToolUseFailure")
|
||||||
writeCopilotObject(t, fixture.configPath, settings)
|
writeCopilotObject(t, fixture.configPath, settings)
|
||||||
status, err = fixture.plugin.Status()
|
status, err = fixture.plugin.Status()
|
||||||
if err != nil || status.HookConfigured || status.Installed {
|
if err != nil || status.HookConfigured || status.Installed {
|
||||||
@@ -177,7 +203,7 @@ func TestCopilotStatusRequiresEnabledPairIdentityAndRuntimeFiles(t *testing.T) {
|
|||||||
runtimeConfig["agent_id"] = "another-agent"
|
runtimeConfig["agent_id"] = "another-agent"
|
||||||
writeCopilotObject(t, fixture.runtimeConfig, runtimeConfig)
|
writeCopilotObject(t, fixture.runtimeConfig, runtimeConfig)
|
||||||
status, err = fixture.plugin.Status()
|
status, err = fixture.plugin.Status()
|
||||||
if err != nil || status.HookScriptExists || status.Installed {
|
if err == nil || status.HookScriptExists || status.Installed {
|
||||||
t.Fatalf("identity mismatch status = %#v, %v", status, err)
|
t.Fatalf("identity mismatch status = %#v, %v", status, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,9 +212,24 @@ func TestCopilotStatusRecognizesActiveProjectDelivery(t *testing.T) {
|
|||||||
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, nil))
|
writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, nil))
|
||||||
writeCopilotObject(t, fixture.runtimeConfig, map[string]any{
|
writeCopilotObject(t, fixture.runtimeConfig, map[string]any{
|
||||||
"agent_id": copilotTestAgentID, "agent_name": copilotAgentKey,
|
"org_id": "org-1", "agent_id": copilotTestAgentID, "kid": "kid-1",
|
||||||
|
"base_url": "https://api.elydora.test", "agent_name": copilotAgentKey,
|
||||||
})
|
})
|
||||||
if err := os.WriteFile(fixture.hookPath, []byte("process.stdin.resume();\n"), 0700); err != nil {
|
if err := os.WriteFile(fixture.privateKey, []byte(copilotTestPrivateKey), 0600); err != nil {
|
||||||
|
t.Fatalf("write private key: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
fixture.guardPath,
|
||||||
|
[]byte(generateGuardScript(copilotAgentKey, copilotTestAgentID, "", false, "")),
|
||||||
|
0700,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write guard runtime: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
fixture.hookPath,
|
||||||
|
[]byte(buildHookScriptWithOutput(copilotAgentKey, copilotTestAgentID, "", false, true)),
|
||||||
|
0700,
|
||||||
|
); err != nil {
|
||||||
t.Fatalf("write audit runtime: %v", err)
|
t.Fatalf("write audit runtime: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +278,8 @@ func TestCopilotUninstallRemovesExactOwnershipAndPreservesUserEntries(t *testing
|
|||||||
|
|
||||||
remaining := readCopilotObject(t, fixture.configPath)
|
remaining := readCopilotObject(t, fixture.configPath)
|
||||||
remainingHooks := requireCopilotObject(t, remaining["hooks"])
|
remainingHooks := requireCopilotObject(t, remaining["hooks"])
|
||||||
if remainingHooks["postToolUse"] != nil || len(requireCopilotArray(t, remainingHooks["preToolUse"])) != 2 {
|
if remainingHooks["postToolUse"] != nil || remainingHooks["postToolUseFailure"] != nil ||
|
||||||
|
len(requireCopilotArray(t, remainingHooks["preToolUse"])) != 2 {
|
||||||
t.Fatalf("remaining hooks = %#v", remainingHooks)
|
t.Fatalf("remaining hooks = %#v", remainingHooks)
|
||||||
}
|
}
|
||||||
if len(requireCopilotArray(t, remainingHooks["notification"])) != 1 {
|
if len(requireCopilotArray(t, remainingHooks["notification"])) != 1 {
|
||||||
|
|||||||
@@ -12,12 +12,20 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
const copilotTestAgentID = "agent-1"
|
const (
|
||||||
|
copilotTestAgentID = "agent-1"
|
||||||
|
copilotTestPrivateKey = "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws"
|
||||||
|
)
|
||||||
|
|
||||||
type copilotFixtureOptions struct {
|
type copilotFixtureOptions struct {
|
||||||
userRaw *string
|
userRaw *string
|
||||||
legacyRaw *string
|
legacyRaw *string
|
||||||
skipGuard bool
|
legacyUserConfigRaw *string
|
||||||
|
userSettingsRaw *string
|
||||||
|
claudeSettingsRaw *string
|
||||||
|
claudeLocalSettingsRaw *string
|
||||||
|
repositorySettingsRaw *string
|
||||||
|
localSettingsRaw *string
|
||||||
emptyOverride bool
|
emptyOverride bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +42,12 @@ type copilotFixture struct {
|
|||||||
hookPath string
|
hookPath string
|
||||||
runtimeConfig string
|
runtimeConfig string
|
||||||
privateKey string
|
privateKey string
|
||||||
|
legacyUserConfig string
|
||||||
|
userSettings string
|
||||||
|
claudeSettings string
|
||||||
|
claudeLocalSettings string
|
||||||
|
repositorySettings string
|
||||||
|
localSettings string
|
||||||
}
|
}
|
||||||
|
|
||||||
type copilotCommandResult struct {
|
type copilotCommandResult struct {
|
||||||
@@ -63,24 +77,27 @@ func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilot
|
|||||||
agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID)
|
agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID)
|
||||||
configPath := filepath.Join(copilotHome, "hooks", copilotConfigFile)
|
configPath := filepath.Join(copilotHome, "hooks", copilotConfigFile)
|
||||||
legacyPath := filepath.Join(projectDir, ".github", "hooks", "hooks.json")
|
legacyPath := filepath.Join(projectDir, ".github", "hooks", "hooks.json")
|
||||||
|
legacyUserConfig := filepath.Join(copilotHome, "config.json")
|
||||||
|
userSettings := filepath.Join(copilotHome, "settings.json")
|
||||||
|
claudeSettings := filepath.Join(projectDir, ".claude", "settings.json")
|
||||||
|
claudeLocalSettings := filepath.Join(projectDir, ".claude", "settings.local.json")
|
||||||
|
repositorySettings := filepath.Join(projectDir, ".github", "copilot", "settings.json")
|
||||||
|
localSettings := filepath.Join(projectDir, ".github", "copilot", "settings.local.json")
|
||||||
guardPath := filepath.Join(agentDir, copilotGuardScript)
|
guardPath := filepath.Join(agentDir, copilotGuardScript)
|
||||||
hookPath := filepath.Join(agentDir, copilotAuditScript)
|
hookPath := filepath.Join(agentDir, copilotAuditScript)
|
||||||
for _, directory := range []string{projectDir, agentDir} {
|
for _, directory := range []string{homeDir, projectDir} {
|
||||||
if err := os.MkdirAll(directory, 0700); err != nil {
|
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||||
t.Fatalf("create fixture directory %s: %v", directory, err)
|
t.Fatalf("create fixture directory %s: %v", directory, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !options.skipGuard {
|
|
||||||
guard := "const fs = require('node:fs');\n" +
|
|
||||||
"fs.readFileSync(0);\n" +
|
|
||||||
"process.stderr.write('Agent is frozen by Elydora.');\n" +
|
|
||||||
"process.exit(2);\n"
|
|
||||||
if err := os.WriteFile(guardPath, []byte(guard), 0700); err != nil {
|
|
||||||
t.Fatalf("write guard runtime: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeOptionalCopilotFile(t, configPath, options.userRaw)
|
writeOptionalCopilotFile(t, configPath, options.userRaw)
|
||||||
writeOptionalCopilotFile(t, legacyPath, options.legacyRaw)
|
writeOptionalCopilotFile(t, legacyPath, options.legacyRaw)
|
||||||
|
writeOptionalCopilotFile(t, legacyUserConfig, options.legacyUserConfigRaw)
|
||||||
|
writeOptionalCopilotFile(t, userSettings, options.userSettingsRaw)
|
||||||
|
writeOptionalCopilotFile(t, claudeSettings, options.claudeSettingsRaw)
|
||||||
|
writeOptionalCopilotFile(t, claudeLocalSettings, options.claudeLocalSettingsRaw)
|
||||||
|
writeOptionalCopilotFile(t, repositorySettings, options.repositorySettingsRaw)
|
||||||
|
writeOptionalCopilotFile(t, localSettings, options.localSettingsRaw)
|
||||||
t.Setenv("HOME", homeDir)
|
t.Setenv("HOME", homeDir)
|
||||||
t.Setenv("USERPROFILE", homeDir)
|
t.Setenv("USERPROFILE", homeDir)
|
||||||
if options.emptyOverride {
|
if options.emptyOverride {
|
||||||
@@ -113,9 +130,15 @@ func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilot
|
|||||||
hookPath: hookPath,
|
hookPath: hookPath,
|
||||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||||
privateKey: filepath.Join(agentDir, "private.key"),
|
privateKey: filepath.Join(agentDir, "private.key"),
|
||||||
|
legacyUserConfig: legacyUserConfig,
|
||||||
|
userSettings: userSettings,
|
||||||
|
claudeSettings: claudeSettings,
|
||||||
|
claudeLocalSettings: claudeLocalSettings,
|
||||||
|
repositorySettings: repositorySettings,
|
||||||
|
localSettings: localSettings,
|
||||||
config: InstallConfig{
|
config: InstallConfig{
|
||||||
AgentName: copilotAgentKey, OrgID: "org-1", AgentID: copilotTestAgentID,
|
AgentName: copilotAgentKey, OrgID: "org-1", AgentID: copilotTestAgentID,
|
||||||
PrivateKey: "test-key", KID: "kid-1", Token: "token-1",
|
PrivateKey: copilotTestPrivateKey, KID: "kid-1", Token: "token-1",
|
||||||
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
|
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -218,6 +241,10 @@ func legacyCopilotConfig(fixture *copilotFixture, extraHooks map[string]any) map
|
|||||||
"type": "command", "bash": "node " + fixture.hookPath,
|
"type": "command", "bash": "node " + fixture.hookPath,
|
||||||
"powershell": "node " + fixture.hookPath, "timeoutSec": copilotLegacyTimeout,
|
"powershell": "node " + fixture.hookPath, "timeoutSec": copilotLegacyTimeout,
|
||||||
}},
|
}},
|
||||||
|
"postToolUseFailure": []any{map[string]any{
|
||||||
|
"type": "command", "bash": "node " + fixture.hookPath,
|
||||||
|
"powershell": "node " + fixture.hookPath, "timeoutSec": copilotLegacyTimeout,
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
for key, value := range extraHooks {
|
for key, value := range extraHooks {
|
||||||
hooks[key] = value
|
hooks[key] = value
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopilotPrepareRejectsSameContentStaleHookSnapshot(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
|
||||||
|
userRaw: copilotJSON(map[string]any{
|
||||||
|
"version": float64(1),
|
||||||
|
"hooks": map[string]any{"notification": []any{}},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
sources, _, err := readCopilotSources()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read GitHub Copilot sources: %v", err)
|
||||||
|
}
|
||||||
|
paths, nodePath, err := preflightCopilotInstallation(fixture.config, sources)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("preflight GitHub Copilot installation: %v", err)
|
||||||
|
}
|
||||||
|
rendered, err := renderCopilotInstallation(
|
||||||
|
sources,
|
||||||
|
paths.guardPath,
|
||||||
|
paths.auditPath,
|
||||||
|
nodePath,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render GitHub Copilot installation: %v", err)
|
||||||
|
}
|
||||||
|
original, err := os.ReadFile(fixture.configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read user hooks: %v", err)
|
||||||
|
}
|
||||||
|
replacement := fixture.configPath + ".replacement"
|
||||||
|
if err := os.WriteFile(replacement, original, 0600); err != nil {
|
||||||
|
t.Fatalf("write replacement hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(replacement, fixture.configPath); err != nil {
|
||||||
|
t.Fatalf("replace user hooks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = prepareCopilotInstallation(fixture.config, sources, rendered)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "changed before update") {
|
||||||
|
t.Fatalf("prepare error = %v", err)
|
||||||
|
}
|
||||||
|
assertCopilotRuntimeAbsent(t, fixture)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopilotInstallRollsBackWhenEffectiveSettingsChange(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
|
||||||
|
userSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": false}),
|
||||||
|
})
|
||||||
|
commits := 0
|
||||||
|
mutated := false
|
||||||
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
|
if err := os.Rename(source, destination); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !mutated && strings.HasSuffix(source, ".tmp") {
|
||||||
|
commits++
|
||||||
|
if commits == 3 {
|
||||||
|
mutated = true
|
||||||
|
writeCopilotObject(t, fixture.userSettings, map[string]any{
|
||||||
|
"disableAllHooks": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "user settings changed") {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
settings := readCopilotObject(t, fixture.userSettings)
|
||||||
|
if settings["disableAllHooks"] != true {
|
||||||
|
t.Fatalf("concurrent settings = %#v", settings)
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("hook source exists after rollback: %v", err)
|
||||||
|
}
|
||||||
|
assertCopilotRuntimeAbsent(t, fixture)
|
||||||
|
assertNoCopilotTransactionArtifacts(t, fixture.homeDir, fixture.projectDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopilotIdempotentCommitChecksSameContentSettingsIdentity(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
|
||||||
|
userSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": false}),
|
||||||
|
})
|
||||||
|
installCopilotFixture(t, fixture)
|
||||||
|
sources, _, err := readCopilotSources()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read GitHub Copilot sources: %v", err)
|
||||||
|
}
|
||||||
|
paths, nodePath, err := preflightCopilotInstallation(fixture.config, sources)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("preflight GitHub Copilot installation: %v", err)
|
||||||
|
}
|
||||||
|
rendered, err := renderCopilotInstallation(
|
||||||
|
sources,
|
||||||
|
paths.guardPath,
|
||||||
|
paths.auditPath,
|
||||||
|
nodePath,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render GitHub Copilot installation: %v", err)
|
||||||
|
}
|
||||||
|
prepared, err := prepareCopilotInstallation(fixture.config, sources, rendered)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare GitHub Copilot installation: %v", err)
|
||||||
|
}
|
||||||
|
original, err := os.ReadFile(fixture.userSettings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read user settings: %v", err)
|
||||||
|
}
|
||||||
|
replacement := fixture.userSettings + ".replacement"
|
||||||
|
if err := os.WriteFile(replacement, original, 0600); err != nil {
|
||||||
|
t.Fatalf("write replacement settings: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(replacement, fixture.userSettings); err != nil {
|
||||||
|
t.Fatalf("replace user settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = commitCopilotInstallation(prepared, fixture.plugin.rename)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "user settings changed") {
|
||||||
|
t.Fatalf("commit error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopilotInstallPreservesOrphanedRuntimeWithoutIdentity(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
|
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
|
||||||
|
t.Fatalf("create agent runtime directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fixture.hookPath, []byte("orphaned\n"), 0700); err != nil {
|
||||||
|
t.Fatalf("write orphaned runtime: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cannot be verified") {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
current, readErr := os.ReadFile(fixture.hookPath)
|
||||||
|
if readErr != nil || string(current) != "orphaned\n" {
|
||||||
|
t.Fatalf("orphaned runtime = %q, %v", current, readErr)
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
fixture.configPath, fixture.guardPath, fixture.runtimeConfig, fixture.privateKey,
|
||||||
|
} {
|
||||||
|
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("unexpected file at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopilotInstallRejectsLinkedHomeAndRuntimeDirectories(t *testing.T) {
|
||||||
|
for _, location := range []string{"home", "runtime"} {
|
||||||
|
t.Run(location, func(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
|
target := filepath.Join(filepath.Dir(fixture.homeDir), location+"-target")
|
||||||
|
if err := os.MkdirAll(target, 0700); err != nil {
|
||||||
|
t.Fatalf("create symlink target: %v", err)
|
||||||
|
}
|
||||||
|
linkPath := fixture.copilotHome
|
||||||
|
if location == "runtime" {
|
||||||
|
linkPath = fixture.agentDir
|
||||||
|
if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil {
|
||||||
|
t.Fatalf("create runtime root: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, linkPath); err != nil {
|
||||||
|
t.Skipf("create directory symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "physical directory") {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopilotStatusRequiresExactRuntimeSourcesAndPrivateKey(t *testing.T) {
|
||||||
|
for _, name := range []string{"guard", "audit"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
|
installCopilotFixture(t, fixture)
|
||||||
|
path := fixture.guardPath
|
||||||
|
if name == "audit" {
|
||||||
|
path = fixture.hookPath
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("// tampered\n"), 0700); err != nil {
|
||||||
|
t.Fatalf("tamper %s runtime: %v", name, err)
|
||||||
|
}
|
||||||
|
status, err := fixture.plugin.Status()
|
||||||
|
if err != nil || status.Installed || status.HookScriptExists {
|
||||||
|
t.Fatalf("tampered %s status = %#v, %v", name, status, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
|
installCopilotFixture(t, fixture)
|
||||||
|
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
|
||||||
|
t.Fatalf("corrupt private key: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fixture.plugin.Status(); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "canonical 32-byte") {
|
||||||
|
t.Fatalf("invalid key status error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopilotValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
edit func(*copilotFixture)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"agent name", func(f *copilotFixture) { f.config.AgentName = "codex" }, "requires agent name copilot"},
|
||||||
|
{"organization", func(f *copilotFixture) { f.config.OrgID = " " }, "organization ID is required"},
|
||||||
|
{"key ID", func(f *copilotFixture) { f.config.KID = "\t" }, "key ID is required"},
|
||||||
|
{"token", func(f *copilotFixture) { f.config.Token = " " }, "token must contain"},
|
||||||
|
{"private key", func(f *copilotFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"},
|
||||||
|
{"base URL", func(f *copilotFixture) {
|
||||||
|
f.config.BaseURL = "https://api.elydora.com/path?token=secret"
|
||||||
|
}, "query parameters"},
|
||||||
|
{"guard path", func(f *copilotFixture) {
|
||||||
|
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
||||||
|
}, "managed agent directory"},
|
||||||
|
{"audit path", func(f *copilotFixture) {
|
||||||
|
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
||||||
|
}, "managed agent directory"},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||||
|
testCase.edit(fixture)
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||||
|
t.Fatalf("install error = %v, want %q", err, testCase.want)
|
||||||
|
}
|
||||||
|
assertCopilotRuntimeAbsent(t, fixture)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,7 +100,12 @@ func prepareSourceChange(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeChanges(changes []*fileChange, label string, rename renameFunc) error {
|
func writeChanges(
|
||||||
|
changes []*fileChange,
|
||||||
|
label string,
|
||||||
|
rename renameFunc,
|
||||||
|
preconditions ...filePrecondition,
|
||||||
|
) error {
|
||||||
filtered := make([]fileChange, 0, len(changes))
|
filtered := make([]fileChange, 0, len(changes))
|
||||||
targets := map[string]bool{}
|
targets := map[string]bool{}
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
@@ -117,6 +122,9 @@ func writeChanges(changes []*fileChange, label string, rename renameFunc) error
|
|||||||
targets[target] = true
|
targets[target] = true
|
||||||
filtered = append(filtered, *change)
|
filtered = append(filtered, *change)
|
||||||
}
|
}
|
||||||
|
if err := assertFilePreconditions(preconditions, label); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", label, err)
|
||||||
|
}
|
||||||
if len(filtered) == 0 {
|
if len(filtered) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -132,13 +140,27 @@ func writeChanges(changes []*fileChange, label string, rename renameFunc) error
|
|||||||
}
|
}
|
||||||
staged = append(staged, item)
|
staged = append(staged, item)
|
||||||
}
|
}
|
||||||
|
if err := assertFilePreconditions(preconditions, label); err != nil {
|
||||||
|
cleanupErrors := cleanupStaging(staged)
|
||||||
|
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), cleanupErrors, "cleanup failed")
|
||||||
|
}
|
||||||
for index := range staged {
|
for index := range staged {
|
||||||
|
if err := assertFilePreconditions(preconditions, label); err != nil {
|
||||||
|
recoveryErrors := rollbackChanges(staged, rename)
|
||||||
|
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
|
||||||
|
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
|
||||||
|
}
|
||||||
if err := commitChange(&staged[index], rename); err != nil {
|
if err := commitChange(&staged[index], rename); err != nil {
|
||||||
recoveryErrors := rollbackChanges(staged, rename)
|
recoveryErrors := rollbackChanges(staged, rename)
|
||||||
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
|
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
|
||||||
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
|
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := assertFilePreconditions(preconditions, label); err != nil {
|
||||||
|
recoveryErrors := rollbackChanges(staged, rename)
|
||||||
|
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
|
||||||
|
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
|
||||||
|
}
|
||||||
cleanupErrors := cleanupStaging(staged)
|
cleanupErrors := cleanupStaging(staged)
|
||||||
if len(cleanupErrors) > 0 {
|
if len(cleanupErrors) > 0 {
|
||||||
return joinTransactionFailure(fmt.Errorf("%s cleanup failed", label), cleanupErrors, "cleanup failed")
|
return joinTransactionFailure(fmt.Errorf("%s cleanup failed", label), cleanupErrors, "cleanup failed")
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type filePrecondition struct {
|
||||||
|
filePath string
|
||||||
|
label string
|
||||||
|
snapshot *managedFileSnapshot
|
||||||
|
maximumSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareSnapshotSourceChange(
|
||||||
|
filePath, label string,
|
||||||
|
original *managedFileSnapshot,
|
||||||
|
next []byte,
|
||||||
|
mode os.FileMode,
|
||||||
|
remove bool,
|
||||||
|
) (*fileChange, error) {
|
||||||
|
if !remove && int64(len(next)) > maxManagedSourceBytes {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s exceeds %d bytes: %s",
|
||||||
|
label,
|
||||||
|
maxManagedSourceBytes,
|
||||||
|
filePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
current, err := readManagedFile(filePath, label, maxManagedSourceBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !sameManagedSnapshot(current, original) {
|
||||||
|
return nil, fmt.Errorf("%s changed before update: %s", label, filePath)
|
||||||
|
}
|
||||||
|
if original == nil && (remove || next == nil) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if original != nil && !remove && bytes.Equal(original.contents, next) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
change := &fileChange{
|
||||||
|
filePath: filePath,
|
||||||
|
label: label,
|
||||||
|
next: append([]byte(nil), next...),
|
||||||
|
mode: mode,
|
||||||
|
remove: remove,
|
||||||
|
}
|
||||||
|
if original != nil {
|
||||||
|
change.original = append([]byte(nil), original.contents...)
|
||||||
|
change.originalInfo = original.info
|
||||||
|
change.originalMode = original.mode
|
||||||
|
change.existed = true
|
||||||
|
}
|
||||||
|
return change, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameManagedSnapshot(current, expected *managedFileSnapshot) bool {
|
||||||
|
if current == nil || expected == nil {
|
||||||
|
return current == expected
|
||||||
|
}
|
||||||
|
return bytes.Equal(current.contents, expected.contents) &&
|
||||||
|
os.SameFile(current.info, expected.info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertFilePreconditions(
|
||||||
|
preconditions []filePrecondition,
|
||||||
|
operation string,
|
||||||
|
) error {
|
||||||
|
for _, condition := range preconditions {
|
||||||
|
maximumSize := condition.maximumSize
|
||||||
|
if maximumSize <= 0 {
|
||||||
|
maximumSize = maxManagedSourceBytes
|
||||||
|
}
|
||||||
|
current, err := readManagedFile(
|
||||||
|
condition.filePath,
|
||||||
|
condition.label,
|
||||||
|
maximumSize,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !sameManagedSnapshot(current, condition.snapshot) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s changed during %s: %s",
|
||||||
|
condition.label,
|
||||||
|
operation,
|
||||||
|
condition.filePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user