fix(go): mirror official Copilot hook contract
This commit is contained in:
1 parent
9b67aad113
commit
42a7653a77
15 files changed
+2241
-509
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
@@ -11,131 +10,214 @@ type CopilotPlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
func (p *CopilotPlugin) Install(config InstallConfig) error {
|
||||
if config.AgentID == "" {
|
||||
return fmt.Errorf("agent ID is required")
|
||||
}
|
||||
sources, err := readCopilotSources()
|
||||
// ManagesGuardRuntime reports that Copilot commits its provider guard with the
|
||||
// audit runtime and hook documents.
|
||||
func (p *CopilotPlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
if sources.user.disabled {
|
||||
return fmt.Errorf(
|
||||
"GitHub Copilot user hooks are disabled by disableAllHooks at %s",
|
||||
sources.user.filePath,
|
||||
_, _, err = preflightCopilotInstallation(config, sources)
|
||||
return err
|
||||
}
|
||||
|
||||
func renderCopilotInstallation(
|
||||
sources *copilotSources,
|
||||
guardPath string,
|
||||
auditPath string,
|
||||
nodePath string,
|
||||
) ([]*copilotRenderedDocument, error) {
|
||||
hooks, err := removeManagedCopilotHooks(sources.user.hooks, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
user, err := renderCopilotDocument(sources.user, hooks)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, fmt.Errorf("render GitHub Copilot user hooks: %w", 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
|
||||
}
|
||||
|
||||
userHooks := removeManagedCopilotHooks(sources.user.hooks, runtimeRoot, "")
|
||||
userHooks["preToolUse"] = append(
|
||||
userHooks["preToolUse"],
|
||||
buildCopilotHandler(nodePath, expectedGuard),
|
||||
)
|
||||
userHooks["postToolUse"] = append(
|
||||
userHooks["postToolUse"],
|
||||
buildCopilotHandler(nodePath, auditPath),
|
||||
)
|
||||
userRendered, err := renderCopilotDocument(sources.user, userHooks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render GitHub Copilot user hooks: %w", err)
|
||||
}
|
||||
rendered := []*copilotRenderedDocument{userRendered}
|
||||
rendered := []*copilotRenderedDocument{user}
|
||||
if sources.legacy != nil {
|
||||
legacyHooks := removeManagedCopilotHooks(sources.legacy.hooks, runtimeRoot, "")
|
||||
legacyRendered, renderErr := renderCopilotDocument(sources.legacy, legacyHooks)
|
||||
if renderErr != nil {
|
||||
return fmt.Errorf("render GitHub Copilot project hooks: %w", renderErr)
|
||||
legacyHooks, removeErr := removeManagedCopilotHooks(sources.legacy.hooks, "")
|
||||
if removeErr != nil {
|
||||
return nil, removeErr
|
||||
}
|
||||
rendered = append(rendered, legacyRendered)
|
||||
legacy, renderErr := renderCopilotDocument(sources.legacy, legacyHooks)
|
||||
if renderErr != nil {
|
||||
return nil, fmt.Errorf("render GitHub Copilot legacy project hooks: %w", renderErr)
|
||||
}
|
||||
rendered = append(rendered, legacy)
|
||||
}
|
||||
changes, err := prepareCopilotInstallationChanges(
|
||||
config,
|
||||
agentDirectory,
|
||||
auditPath,
|
||||
rendered,
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
func (p *CopilotPlugin) Install(config InstallConfig) error {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
fmt.Printf(" GitHub Copilot CLI hooks: %s\n", sources.user.filePath)
|
||||
if err := commitCopilotInstallation(prepared, p.rename); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (p *CopilotPlugin) Uninstall(agentID string) error {
|
||||
sources, err := readCopilotSources()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
func renderCopilotUninstall(
|
||||
sources *copilotSources,
|
||||
agentID string,
|
||||
) ([]*copilotRenderedDocument, error) {
|
||||
documents := []*copilotDocument{sources.user}
|
||||
if sources.legacy != nil {
|
||||
documents = append(documents, sources.legacy)
|
||||
}
|
||||
changes := make([]*fileChange, 0, len(documents))
|
||||
rendered := make([]*copilotRenderedDocument, 0, len(documents))
|
||||
for _, document := range documents {
|
||||
hooks := removeManagedCopilotHooks(document.hooks, runtimeRoot, agentID)
|
||||
rendered, renderErr := renderCopilotDocument(document, hooks)
|
||||
if renderErr != nil {
|
||||
return fmt.Errorf("render GitHub Copilot hook source: %w", renderErr)
|
||||
hooks, err := removeManagedCopilotHooks(document.hooks, agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return changeErr
|
||||
}
|
||||
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) {
|
||||
userPath, _, pathErr := copilotConfigPaths()
|
||||
paths, pathErr := resolveCopilotPaths()
|
||||
entry := SupportedAgents[copilotAgentKey]
|
||||
status := PluginStatus{
|
||||
AgentName: copilotAgentKey, DisplayName: entry.Name, ConfigPath: userPath,
|
||||
}
|
||||
status := PluginStatus{AgentName: copilotAgentKey, DisplayName: entry.Name}
|
||||
if pathErr != nil {
|
||||
return status, pathErr
|
||||
}
|
||||
sources, err := readCopilotSources()
|
||||
status.ConfigPath = paths.userHookPath
|
||||
sources, _, err := readCopilotSources()
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
contracts, err := mergedCopilotContracts(sources)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
contracts := activeCopilotContracts(sources, runtimeRoot)
|
||||
status.ConfigPath = configuredCopilotPath(sources, runtimeRoot)
|
||||
status.HookConfigured = len(contracts) > 0
|
||||
status.ConfigPath, err = configuredCopilotPath(sources, paths.userHookPath)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
status.HookConfigured = sources.disabledBy == "" && len(contracts) > 0
|
||||
if !status.HookConfigured {
|
||||
return status, nil
|
||||
}
|
||||
@@ -146,20 +228,3 @@ func (p *CopilotPlugin) Status() (PluginStatus, error) {
|
||||
status.Installed = status.HookScriptExists
|
||||
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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
@@ -18,20 +20,38 @@ const (
|
||||
copilotPOSIXApostrophe = `'"'"'`
|
||||
)
|
||||
|
||||
var copilotManagedEvents = []struct {
|
||||
event string
|
||||
script string
|
||||
}{
|
||||
{"preToolUse", copilotGuardScript},
|
||||
{"postToolUse", copilotAuditScript},
|
||||
{"postToolUseFailure", copilotAuditScript},
|
||||
}
|
||||
|
||||
type copilotHooks map[string][]map[string]any
|
||||
|
||||
type copilotDocument struct {
|
||||
exists bool
|
||||
exists bool
|
||||
filePath string
|
||||
root map[string]any
|
||||
hooks copilotHooks
|
||||
raw []byte
|
||||
snapshot *managedFileSnapshot
|
||||
hooksDisabled bool
|
||||
}
|
||||
|
||||
type copilotSourcePrecondition struct {
|
||||
filePath string
|
||||
root map[string]any
|
||||
hooks copilotHooks
|
||||
raw []byte
|
||||
disabled bool
|
||||
label string
|
||||
snapshot *managedFileSnapshot
|
||||
}
|
||||
|
||||
type copilotSources struct {
|
||||
user *copilotDocument
|
||||
legacy *copilotDocument
|
||||
user *copilotDocument
|
||||
legacy *copilotDocument
|
||||
disabledBy string
|
||||
settingsPreconditions []copilotSourcePrecondition
|
||||
}
|
||||
|
||||
type copilotRenderedDocument struct {
|
||||
@@ -47,10 +67,25 @@ type copilotRuntimeContract struct {
|
||||
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 {
|
||||
clone := make(copilotHooks, len(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
|
||||
}
|
||||
@@ -77,6 +112,13 @@ func sameCopilotAgentID(left, right string) bool {
|
||||
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 {
|
||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||
}
|
||||
@@ -198,40 +240,47 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) {
|
||||
return parseCopilotLegacyCommand(bash)
|
||||
}
|
||||
|
||||
func copilotManagedAgentID(
|
||||
func copilotManagedEntryForHandler(
|
||||
handler map[string]any,
|
||||
scriptName string,
|
||||
runtimeRoot string,
|
||||
) (string, bool) {
|
||||
) (*copilotManagedEntry, error) {
|
||||
scriptPath, managed := copilotManagedScriptPath(handler)
|
||||
if !managed || !filepath.IsAbs(scriptPath) || filepath.Base(scriptPath) != scriptName {
|
||||
return "", false
|
||||
if !managed || !filepath.IsAbs(scriptPath) ||
|
||||
!sameCopilotFileName(filepath.Base(scriptPath), scriptName) {
|
||||
return nil, nil
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentDirectory := filepath.Dir(scriptPath)
|
||||
if !sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
return "", false
|
||||
return nil, nil
|
||||
}
|
||||
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(
|
||||
hooks copilotHooks,
|
||||
runtimeRoot string,
|
||||
agentID string,
|
||||
) copilotHooks {
|
||||
) (copilotHooks, error) {
|
||||
result := cloneCopilotHooks(hooks)
|
||||
for _, contract := range []struct{ event, script string }{
|
||||
{"preToolUse", copilotGuardScript},
|
||||
{"postToolUse", copilotAuditScript},
|
||||
} {
|
||||
for _, contract := range copilotManagedEvents {
|
||||
kept := make([]map[string]any, 0, len(result[contract.event]))
|
||||
for _, handler := range result[contract.event] {
|
||||
managedID, managed := copilotManagedAgentID(handler, contract.script, runtimeRoot)
|
||||
if managed && (agentID == "" || sameCopilotAgentID(managedID, agentID)) {
|
||||
continue
|
||||
entry, err := copilotManagedEntryForHandler(handler, contract.script)
|
||||
if err != nil {
|
||||
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 {
|
||||
delete(result, contract.event)
|
||||
@@ -239,7 +288,65 @@ func removeManagedCopilotHooks(
|
||||
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(
|
||||
@@ -249,22 +356,12 @@ func renderCopilotDocument(
|
||||
if !document.exists && len(hooks) == 0 {
|
||||
return &copilotRenderedDocument{document: document}, nil
|
||||
}
|
||||
if document.exists && len(hooks) == 0 {
|
||||
owned := true
|
||||
for key := range document.root {
|
||||
if key != "version" && key != "hooks" {
|
||||
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
|
||||
if document.exists && emptyOwnedCopilotDocument(document.root, hooks) {
|
||||
return &copilotRenderedDocument{
|
||||
document: document, changed: true, remove: true,
|
||||
}, nil
|
||||
}
|
||||
root := cloneCopilotObject(document.root)
|
||||
root["version"] = float64(1)
|
||||
if len(hooks) == 0 {
|
||||
delete(root, "hooks")
|
||||
@@ -273,58 +370,81 @@ func renderCopilotDocument(
|
||||
}
|
||||
next, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("encode GitHub Copilot hooks: %w", err)
|
||||
}
|
||||
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{
|
||||
document: document,
|
||||
changed: !document.exists || string(next) != string(document.raw),
|
||||
changed: !document.exists || !bytes.Equal(next, document.raw),
|
||||
next: next,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func copilotRuntimeContracts(
|
||||
hooks copilotHooks,
|
||||
runtimeRoot string,
|
||||
) []copilotRuntimeContract {
|
||||
guards := managedCopilotIDs(hooks["preToolUse"], copilotGuardScript, runtimeRoot)
|
||||
audits := managedCopilotIDs(hooks["postToolUse"], copilotAuditScript, runtimeRoot)
|
||||
func copilotEntryKey(agentID string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(agentID)
|
||||
}
|
||||
return agentID
|
||||
}
|
||||
|
||||
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))
|
||||
for key := range guards {
|
||||
if _, exists := audits[key]; exists {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
contracts := make([]copilotRuntimeContract, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
agentID := guards[key]
|
||||
agentDirectory := filepath.Join(runtimeRoot, agentID)
|
||||
contracts = append(contracts, copilotRuntimeContract{
|
||||
agentID: agentID,
|
||||
guardPath: filepath.Join(agentDirectory, copilotGuardScript),
|
||||
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 {
|
||||
guard := guards[key]
|
||||
success := successes[key]
|
||||
failure := failures[key]
|
||||
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||
!sameCopilotPath(success[0].scriptPath, failure[0].scriptPath) {
|
||||
continue
|
||||
}
|
||||
key := agentID
|
||||
if runtime.GOOS == "windows" {
|
||||
key = strings.ToLower(agentID)
|
||||
}
|
||||
result[key] = agentID
|
||||
contracts = append(contracts, copilotRuntimeContract{
|
||||
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
||||
auditPath: success[0].scriptPath,
|
||||
})
|
||||
}
|
||||
return result
|
||||
return contracts, nil
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"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()
|
||||
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"))
|
||||
if copilotHome == "" {
|
||||
override := os.Getenv("COPILOT_HOME")
|
||||
copilotHome := override
|
||||
if strings.TrimSpace(override) == "" {
|
||||
copilotHome = filepath.Join(home, ".copilot")
|
||||
} else if !filepath.IsAbs(copilotHome) {
|
||||
copilotHome, err = filepath.Abs(copilotHome)
|
||||
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 {
|
||||
return "", "", fmt.Errorf("resolve working directory: %w", err)
|
||||
return nil, fmt.Errorf("resolve working directory: %w", err)
|
||||
}
|
||||
return filepath.Join(copilotHome, "hooks", copilotConfigFile),
|
||||
filepath.Join(workingDirectory, ".github", "hooks", "hooks.json"), nil
|
||||
}
|
||||
|
||||
func readCopilotHooks(value any, label string) (copilotHooks, error) {
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
|
||||
}
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
|
||||
}
|
||||
hooks := make(copilotHooks, len(object))
|
||||
for event, handlerValue := range object {
|
||||
values, ok := handlerValue.([]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)
|
||||
if !ok || handler == nil {
|
||||
return nil, fmt.Errorf(`%s handler hooks.%s[%d] must be an object`, label, event, index)
|
||||
}
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
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,
|
||||
github := filepath.Join(project, ".github")
|
||||
githubCopilot := filepath.Join(github, "copilot")
|
||||
githubHooks := filepath.Join(github, "hooks")
|
||||
claude := filepath.Join(project, ".claude")
|
||||
userHooks := filepath.Join(copilotHome, "hooks")
|
||||
return &copilotPaths{
|
||||
copilotHome: copilotHome,
|
||||
userHooksDirectory: userHooks,
|
||||
userHookPath: filepath.Join(userHooks, copilotConfigFile),
|
||||
legacyHookPath: filepath.Join(githubHooks, "hooks.json"),
|
||||
settingsLayers: []copilotSettingsLayer{
|
||||
{filepath.Join(copilotHome, "config.json"), "legacy Copilot user config", false},
|
||||
{filepath.Join(copilotHome, "settings.json"), "Copilot user settings", true},
|
||||
{filepath.Join(claude, "settings.json"), "Claude repository settings", true},
|
||||
{filepath.Join(claude, "settings.local.json"), "Claude local settings", true},
|
||||
{filepath.Join(githubCopilot, "settings.json"), "Copilot repository settings", true},
|
||||
{filepath.Join(githubCopilot, "settings.local.json"), "Copilot local settings", true},
|
||||
},
|
||||
directories: []copilotDirectoryLocation{
|
||||
{project, "Copilot working directory"},
|
||||
{copilotHome, "COPILOT_HOME"},
|
||||
{userHooks, "Copilot user hooks directory"},
|
||||
{github, "GitHub configuration directory"},
|
||||
{githubHooks, "GitHub repository hooks directory"},
|
||||
{githubCopilot, "Copilot repository settings directory"},
|
||||
{claude, "Claude repository settings directory"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readCopilotDocument(filePath, label string) (*copilotDocument, error) {
|
||||
info, err := os.Lstat(filePath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
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, 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) {
|
||||
userPath, legacyPath, err := copilotConfigPaths()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := readCopilotDocument(userPath, "GitHub Copilot user hooks")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacy, err := readCopilotDocument(legacyPath, "GitHub Copilot project hooks")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
user = &copilotDocument{
|
||||
filePath: userPath,
|
||||
root: map[string]any{},
|
||||
hooks: copilotHooks{},
|
||||
func inspectCopilotDirectories(locations []copilotDirectoryLocation) error {
|
||||
for _, location := range locations {
|
||||
if _, err := managedPhysicalDirectoryExists(location.path, location.label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return &copilotSources{user: user, legacy: legacy}, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func readCopilotDocument(filePath, label string) (*copilotDocument, error) {
|
||||
snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes)
|
||||
if err != nil || snapshot == nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseCopilotDocument(filePath, snapshot, label)
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
user = createCopilotDocument(paths.userHookPath)
|
||||
}
|
||||
disabledBy := effectiveCopilotDisabledSource(settings)
|
||||
if user.hooksDisabled {
|
||||
disabledBy = fmt.Sprintf("GitHub Copilot user hooks at %s", paths.userHookPath)
|
||||
}
|
||||
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(
|
||||
@@ -144,166 +222,41 @@ func prepareRenderedCopilotChange(
|
||||
if rendered == nil || !rendered.changed {
|
||||
return nil, nil
|
||||
}
|
||||
return prepareSourceChange(
|
||||
return prepareSnapshotSourceChange(
|
||||
rendered.document.filePath,
|
||||
"GitHub Copilot hook source",
|
||||
rendered.document.raw,
|
||||
rendered.document.exists,
|
||||
rendered.document.snapshot,
|
||||
rendered.next,
|
||||
0600,
|
||||
rendered.remove,
|
||||
)
|
||||
}
|
||||
|
||||
func prepareCopilotInstallationChanges(
|
||||
config InstallConfig,
|
||||
func writeCopilotChanges(
|
||||
changes []*fileChange,
|
||||
label string,
|
||||
rename renameFunc,
|
||||
runtimeRoot string,
|
||||
agentDirectory string,
|
||||
auditPath string,
|
||||
rendered []*copilotRenderedDocument,
|
||||
) ([]*fileChange, error) {
|
||||
baseURL := config.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.elydora.com"
|
||||
}
|
||||
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
|
||||
preconditions []filePrecondition,
|
||||
) error {
|
||||
hasChanges := false
|
||||
for _, change := range changes {
|
||||
if change != nil {
|
||||
hasChanges = true
|
||||
break
|
||||
}
|
||||
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)
|
||||
if !hasChanges {
|
||||
return writeChanges(changes, label, rename, preconditions...)
|
||||
}
|
||||
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
|
||||
if agentDirectory != "" {
|
||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
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)
|
||||
}
|
||||
|
||||
func TestCopilotInstallRejectsMissingOrUnmanagedGuardBeforeWrites(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{})
|
||||
unmanaged := filepath.Join(fixture.homeDir, "unmanaged-guard.js")
|
||||
if err := os.WriteFile(unmanaged, []byte("process.exit(2);\n"), 0700); err != nil {
|
||||
t.Fatalf("write unmanaged guard: %v", err)
|
||||
}
|
||||
fixture.config.GuardScriptPath = unmanaged
|
||||
if err := fixture.plugin.Install(fixture.config); err == nil {
|
||||
t.Fatal("install accepted an unmanaged guard runtime")
|
||||
}
|
||||
assertCopilotRuntimeAbsent(t, fixture)
|
||||
})
|
||||
func TestCopilotInstallGeneratesManagedGuardAndRejectsUnmanagedPath(t *testing.T) {
|
||||
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||
installCopilotFixture(t, fixture)
|
||||
expected := generateGuardScript(copilotAgentKey, copilotTestAgentID, "", false, "")
|
||||
actual, err := os.ReadFile(fixture.guardPath)
|
||||
if err != nil || string(actual) != expected {
|
||||
t.Fatalf("generated guard = %q, %v", actual, err)
|
||||
}
|
||||
|
||||
fixture = prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||
fixture.config.GuardScriptPath = filepath.Join(fixture.homeDir, "unmanaged-guard.js")
|
||||
if err := fixture.plugin.Install(fixture.config); err == nil {
|
||||
t.Fatal("install accepted an unmanaged guard runtime")
|
||||
}
|
||||
assertCopilotRuntimeAbsent(t, fixture)
|
||||
}
|
||||
|
||||
func TestCopilotInstallRejectsSymlinkFiles(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")
|
||||
if err := os.WriteFile(target, []byte("process.exit(2);\n"), 0700); err != nil {
|
||||
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 {
|
||||
t.Skipf("create guard symlink: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err == nil {
|
||||
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) {
|
||||
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||
@@ -248,7 +252,9 @@ func TestCopilotInstallDetectsConcurrentSourceChangeAndRollsBack(t *testing.T) {
|
||||
|
||||
func assertCopilotRuntimeAbsent(t *testing.T, fixture *copilotFixture) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
@@ -48,7 +53,8 @@ func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testin
|
||||
}
|
||||
preHooks := requireCopilotArray(t, hooks["preToolUse"])
|
||||
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)
|
||||
}
|
||||
if requireCopilotObject(t, preHooks[0])["command"] != "user-pre" {
|
||||
@@ -60,6 +66,9 @@ func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testin
|
||||
assertNativeCopilotHandler(t, managedCopilotTestHandler(
|
||||
t, settings, "postToolUse", copilotAuditScript,
|
||||
))
|
||||
assertNativeCopilotHandler(t, managedCopilotTestHandler(
|
||||
t, settings, "postToolUseFailure", copilotAuditScript,
|
||||
))
|
||||
legacy := readCopilotObject(t, fixture.legacyPath)
|
||||
legacyHooks := requireCopilotObject(t, legacy["hooks"])
|
||||
if len(legacyHooks) != 1 || legacyHooks["notification"] == nil {
|
||||
@@ -98,6 +107,13 @@ func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) {
|
||||
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||
installCopilotFixture(t, fixture)
|
||||
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" +
|
||||
"fs.writeFileSync(process.env.ELYDORA_CAPTURE, fs.readFileSync(0));\n"
|
||||
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") {
|
||||
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)
|
||||
auditResult := runCopilotHandler(t, audit, postPayload, "ELYDORA_CAPTURE="+capturePath)
|
||||
if auditResult.exitCode != 0 {
|
||||
@@ -120,6 +136,16 @@ func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) {
|
||||
if err != nil || string(captured) != postPayload {
|
||||
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) {
|
||||
@@ -146,7 +172,7 @@ func TestCopilotStatusRequiresEnabledPairIdentityAndRuntimeFiles(t *testing.T) {
|
||||
|
||||
settings := readCopilotObject(t, fixture.configPath)
|
||||
hooks := requireCopilotObject(t, settings["hooks"])
|
||||
delete(hooks, "postToolUse")
|
||||
delete(hooks, "postToolUseFailure")
|
||||
writeCopilotObject(t, fixture.configPath, settings)
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.HookConfigured || status.Installed {
|
||||
@@ -177,7 +203,7 @@ func TestCopilotStatusRequiresEnabledPairIdentityAndRuntimeFiles(t *testing.T) {
|
||||
runtimeConfig["agent_id"] = "another-agent"
|
||||
writeCopilotObject(t, fixture.runtimeConfig, runtimeConfig)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -186,9 +212,24 @@ func TestCopilotStatusRecognizesActiveProjectDelivery(t *testing.T) {
|
||||
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
|
||||
writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, nil))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -237,7 +278,8 @@ func TestCopilotUninstallRemovesExactOwnershipAndPreservesUserEntries(t *testing
|
||||
|
||||
remaining := readCopilotObject(t, fixture.configPath)
|
||||
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)
|
||||
}
|
||||
if len(requireCopilotArray(t, remainingHooks["notification"])) != 1 {
|
||||
|
||||
@@ -12,28 +12,42 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
const copilotTestAgentID = "agent-1"
|
||||
const (
|
||||
copilotTestAgentID = "agent-1"
|
||||
copilotTestPrivateKey = "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws"
|
||||
)
|
||||
|
||||
type copilotFixtureOptions struct {
|
||||
userRaw *string
|
||||
legacyRaw *string
|
||||
skipGuard bool
|
||||
emptyOverride bool
|
||||
userRaw *string
|
||||
legacyRaw *string
|
||||
legacyUserConfigRaw *string
|
||||
userSettingsRaw *string
|
||||
claudeSettingsRaw *string
|
||||
claudeLocalSettingsRaw *string
|
||||
repositorySettingsRaw *string
|
||||
localSettingsRaw *string
|
||||
emptyOverride bool
|
||||
}
|
||||
|
||||
type copilotFixture struct {
|
||||
plugin *CopilotPlugin
|
||||
config InstallConfig
|
||||
homeDir string
|
||||
projectDir string
|
||||
copilotHome string
|
||||
agentDir string
|
||||
configPath string
|
||||
legacyPath string
|
||||
guardPath string
|
||||
hookPath string
|
||||
runtimeConfig string
|
||||
privateKey string
|
||||
plugin *CopilotPlugin
|
||||
config InstallConfig
|
||||
homeDir string
|
||||
projectDir string
|
||||
copilotHome string
|
||||
agentDir string
|
||||
configPath string
|
||||
legacyPath string
|
||||
guardPath string
|
||||
hookPath string
|
||||
runtimeConfig string
|
||||
privateKey string
|
||||
legacyUserConfig string
|
||||
userSettings string
|
||||
claudeSettings string
|
||||
claudeLocalSettings string
|
||||
repositorySettings string
|
||||
localSettings string
|
||||
}
|
||||
|
||||
type copilotCommandResult struct {
|
||||
@@ -63,24 +77,27 @@ func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilot
|
||||
agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID)
|
||||
configPath := filepath.Join(copilotHome, "hooks", copilotConfigFile)
|
||||
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)
|
||||
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 {
|
||||
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, 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("USERPROFILE", homeDir)
|
||||
if options.emptyOverride {
|
||||
@@ -102,20 +119,26 @@ func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilot
|
||||
}
|
||||
})
|
||||
return &copilotFixture{
|
||||
plugin: &CopilotPlugin{},
|
||||
homeDir: homeDir,
|
||||
projectDir: projectDir,
|
||||
copilotHome: copilotHome,
|
||||
agentDir: agentDir,
|
||||
configPath: configPath,
|
||||
legacyPath: legacyPath,
|
||||
guardPath: guardPath,
|
||||
hookPath: hookPath,
|
||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||
privateKey: filepath.Join(agentDir, "private.key"),
|
||||
plugin: &CopilotPlugin{},
|
||||
homeDir: homeDir,
|
||||
projectDir: projectDir,
|
||||
copilotHome: copilotHome,
|
||||
agentDir: agentDir,
|
||||
configPath: configPath,
|
||||
legacyPath: legacyPath,
|
||||
guardPath: guardPath,
|
||||
hookPath: hookPath,
|
||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||
privateKey: filepath.Join(agentDir, "private.key"),
|
||||
legacyUserConfig: legacyUserConfig,
|
||||
userSettings: userSettings,
|
||||
claudeSettings: claudeSettings,
|
||||
claudeLocalSettings: claudeLocalSettings,
|
||||
repositorySettings: repositorySettings,
|
||||
localSettings: localSettings,
|
||||
config: InstallConfig{
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -218,6 +241,10 @@ func legacyCopilotConfig(fixture *copilotFixture, extraHooks map[string]any) map
|
||||
"type": "command", "bash": "node " + fixture.hookPath,
|
||||
"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 {
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
targets := map[string]bool{}
|
||||
for _, change := range changes {
|
||||
@@ -117,6 +122,9 @@ func writeChanges(changes []*fileChange, label string, rename renameFunc) error
|
||||
targets[target] = true
|
||||
filtered = append(filtered, *change)
|
||||
}
|
||||
if err := assertFilePreconditions(preconditions, label); err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -132,13 +140,27 @@ func writeChanges(changes []*fileChange, label string, rename renameFunc) error
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
recoveryErrors := rollbackChanges(staged, rename)
|
||||
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
|
||||
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)
|
||||
if len(cleanupErrors) > 0 {
|
||||
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