diff --git a/AGENTS.md b/AGENTS.md index e4d6fae..b3d059b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,8 +38,9 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh - Cline CLI 3.0.46 discovers exact `PreToolUse` and `PostToolUse` file basenames across `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, workspace `.clinerules/hooks`, and workspace `.cline/hooks`; Elydora owns the `$CLINE_DIR/hooks` pair and keeps the other roots read-only. Commit both wrappers, generated guard and audit runtimes, strict config, and canonical private key through one rollback-capable transaction. Preserve official input byte-for-byte, emit pure JSON cancellation control for guard exit code `2`, reject linked managed paths, and require exact generated sources plus runtime identity for healthy status. Cline surfaces hook errors and continues the task, so wrapper stderr and runtime `error.log` are operational evidence. - Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Keep repository, Claude-compatible, inline, plugin, and policy hook sources additive; migrate only exact Elydora-owned entries from project `.github/hooks/hooks.json`. Resolve `disableAllHooks` through legacy user config, user settings, Claude repository settings, and Copilot repository settings in official precedence order; settings accept JSONC and trailing commas, while hook documents remain strict JSON. - Validate the complete GitHub Copilot CLI event and command, HTTP, and prompt handler schema before writes, then validate matchers with Node.js regular-expression semantics during install. Own one exact `preToolUse`, `postToolUse`, and `postToolUseFailure` triple, preserve native camelCase payloads, and propagate freeze and revocation through exit code `2`. Generate guard, config, private key, and audit runtimes with both hook documents in one rollback-capable transaction guarded by read-only settings snapshots. Healthy status requires the enabled three-event contract, strict runtime identity, canonical private key, and exact generated runtime sources. -- Factory Droid `droid@0.175.0` reads direct event maps from `~/.factory/hooks.json` or the legacy `~/.factory/hooks/hooks.json`; `~/.factory/settings.json` stores the same map under `hooks`. Select the active source per event, preserve JSONC syntax, and direct users to `/hooks` for review. -- Commit Factory Droid runtime config, private key, audit runtime, and affected hook documents in one rollback-capable transaction. Status requires an enabled `PreToolUse`/`PostToolUse` pair with matching managed runtime files. +- Factory Droid `droid@0.175.0` selects one complete user hook source in this order: `~/.factory/hooks.json`, legacy `~/.factory/hooks/hooks.json`, `~/.factory/settings.local.json` `hooks`, then `~/.factory/settings.json` `hooks`. Write the current root `{ "hooks": { ... } }` container, accept unknown future event arrays, preserve JSONC syntax, and remove exact Elydora entries from every discovered user source during migration or uninstall. +- Resolve Factory `hooksDisabled` through system, project, nested-folder, and matching `settings.local.json` extension-only precedence before user settings. Treat system `allowManagedHooksOnly` as an installation gate, snapshot every read-only policy layer as a transaction precondition, and direct users to `/hooks` for local and organization-managed review. +- Generate Factory Droid guard, strict config, canonical private key, and native-payload audit runtime with every affected hook document in one rollback-capable transaction. Windows commands use Factory's native PowerShell contract with explicit `$LASTEXITCODE` propagation. Healthy status requires one exact current `PreToolUse`/`PostToolUse` pair, strict runtime identity, physical files, and exact generated runtime sources. - Qwen Code `0.20.0` loads user hooks from `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default; follow Qwen's `.env` precedence and keep workspace settings read-only. Preserve comments, reject trailing commas and duplicate keys, express timeouts in milliseconds, and direct users to `/hooks` for review. - Commit Qwen Code runtime config, private key, audit runtime, and user settings in one rollback-capable transaction. Status requires enabled `PreToolUse` and `PostToolUse` hooks with matching managed runtime files. diff --git a/README.md b/README.md index cb381ef..a0502ec 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `P Auggie installation commits `~/.augment/settings.json`, both platform wrappers, generated runtimes, runtime config, and private key as one rollback-capable transaction. The managed `PreToolUse` guard propagates exit code `2`; `PostToolUse` preserves Auggie's complete native hook payload. System, workspace, local workspace, and alternate `--augment-cache-dir` settings remain unchanged. Run `auggie tools list` to validate the effective user configuration. -Factory Droid installation selects `PreToolUse` and `PostToolUse` independently across `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, and `~/.factory/settings.json`. Elydora preserves JSONC comments and formatting, commits runtime and hook changes as one transaction, and reports disabled hooks as unhealthy. Run `/hooks` in Droid after installation to review the effective hooks. +Factory Droid 0.175 installation follows its whole-source precedence: `~/.factory/hooks.json`, legacy `~/.factory/hooks/hooks.json`, `~/.factory/settings.local.json` `hooks`, then `~/.factory/settings.json` `hooks`. Elydora writes the current `{ "hooks": { ... } }` root contract, preserves JSONC comments and formatting, accepts future hook events, and removes exact legacy Elydora commands during migration. System, project, and nested-folder settings remain read-only policy inputs; `hooksDisabled` and organization-managed hook policy block installation before runtime creation. The guard, native-payload audit runtime, strict config, canonical private key, and every affected hook document commit in one rollback-capable transaction. Windows commands use Factory's native PowerShell execution contract and propagate `$LASTEXITCODE`. Run `/hooks` in Droid after installation to review the effective local and organization-managed hooks. Qwen Code installation writes native user hooks to `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default. Elydora follows Qwen's `.env` discovery order, preserves JSON-with-comments source formatting, keeps workspace settings read-only, and commits runtime and settings changes as one transaction. Run `/hooks` in Qwen Code after installation to review the effective hooks. diff --git a/cmd/elydora/droid_cli_test.go b/cmd/elydora/droid_cli_test.go new file mode 100644 index 0000000..cd1a381 --- /dev/null +++ b/cmd/elydora/droid_cli_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +const droidCLIPrivateKey = "DQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0" + +func prepareDroidCLIEnvironment(t *testing.T) (string, string, []string) { + t.Helper() + root := t.TempDir() + homeDir := filepath.Join(root, "home with spaces and 'quote %DROID%") + workspace := filepath.Join(root, "workspace with spaces") + if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0700); err != nil { + t.Fatalf("create Factory Droid CLI workspace: %v", err) + } + return homeDir, workspace, []string{ + "HOME=" + homeDir, + "USERPROFILE=" + homeDir, + } +} + +func TestDroidCLIPreflightBlocksPolicyBeforeRuntimeCreation(t *testing.T) { + binary := buildClineCLIBinary(t) + homeDir, workspace, environment := prepareDroidCLIEnvironment(t) + settingsPath := filepath.Join(homeDir, ".factory", "settings.json") + if err := os.MkdirAll(filepath.Dir(settingsPath), 0700); err != nil { + t.Fatalf("create Factory settings directory: %v", err) + } + original := "{\"hooksDisabled\":true}\n" + if err := os.WriteFile(settingsPath, []byte(original), 0600); err != nil { + t.Fatalf("write Factory settings: %v", err) + } + keyPath := filepath.Join(t.TempDir(), "private.key") + writeClineCLISecret(t, keyPath, droidCLIPrivateKey) + _, stderr, exit := runClineCLI( + t, + binary, + workspace, + environment, + "install", + "--agent", "droid", + "--org-id", "org-1", + "--agent-id", "agent-1", + "--private-key-file", keyPath, + "--kid", "kid-1", + "--base-url", "https://api.elydora.test", + ) + if exit == 0 || !strings.Contains(stderr, "hooksDisabled") { + t.Fatalf("Factory Droid CLI preflight = %d, %q", exit, stderr) + } + raw, err := os.ReadFile(settingsPath) + if err != nil || string(raw) != original { + t.Fatalf("Factory settings changed: %q, %v", raw, err) + } + for _, path := range []string{ + filepath.Join(homeDir, ".elydora"), + filepath.Join(homeDir, ".factory", "hooks.json"), + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("path created during failed preflight at %s: %v", path, err) + } + } +} + +func TestDroidCLIInstallStatusAndUninstallEndToEnd(t *testing.T) { + binary := buildClineCLIBinary(t) + homeDir, workspace, environment := prepareDroidCLIEnvironment(t) + keyPath := filepath.Join(t.TempDir(), "private.key") + tokenPath := filepath.Join(t.TempDir(), "token") + writeClineCLISecret(t, keyPath, droidCLIPrivateKey) + writeClineCLISecret(t, tokenPath, "token-1") + stdout, stderr, exit := runClineCLI( + t, + binary, + workspace, + environment, + "install", + "--agent", "droid", + "--org-id", "org-1", + "--agent-id", "agent-1", + "--private-key-file", keyPath, + "--token-file", tokenPath, + "--kid", "kid-1", + "--base-url", "http://127.0.0.1:9", + ) + if exit != 0 || stderr != "" || !strings.Contains(stdout, "Factory Droid") { + t.Fatalf("Factory Droid CLI install = %d, %q, %q", exit, stdout, stderr) + } + agentDir := filepath.Join(homeDir, ".elydora", "agent-1") + hooksPath := filepath.Join(homeDir, ".factory", "hooks.json") + for _, path := range []string{ + filepath.Join(agentDir, "guard.js"), + filepath.Join(agentDir, "config.json"), + filepath.Join(agentDir, "private.key"), + filepath.Join(agentDir, "hook.js"), + hooksPath, + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("installed file missing at %s: %v", path, err) + } + } + hooks, err := os.ReadFile(hooksPath) + if err != nil || !strings.Contains(string(hooks), `"hooks"`) || + !strings.Contains(string(hooks), `"PreToolUse"`) || + !strings.Contains(string(hooks), `"PostToolUse"`) { + t.Fatalf("Factory Droid hooks = %q, %v", hooks, err) + } + stdout, stderr, exit = runClineCLI( + t, + binary, + workspace, + environment, + "status", + "--agent", "droid", + ) + if exit != 0 || stderr != "" || !strings.Contains(stdout, "installed") { + t.Fatalf("Factory Droid CLI status = %d, %q, %q", exit, stdout, stderr) + } + stdout, stderr, exit = runClineCLI( + t, + binary, + workspace, + environment, + "uninstall", + "--agent", "droid", + "--agent-id", "agent-1", + ) + if exit != 0 || stderr != "" || !strings.Contains(stdout, "Removed agent directory") { + t.Fatalf("Factory Droid CLI uninstall = %d, %q, %q", exit, stdout, stderr) + } + for _, path := range []string{agentDir, hooksPath} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed path remains at %s: %v", path, err) + } + } +} diff --git a/cmd/elydora/plugins/droid.go b/cmd/elydora/plugins/droid.go index 9dc3984..97fcee9 100644 --- a/cmd/elydora/plugins/droid.go +++ b/cmd/elydora/plugins/droid.go @@ -1,145 +1,143 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) +import "fmt" +// DroidPlugin manages Factory Droid native user hooks. type DroidPlugin struct { rename renameFunc } -type droidRuntimeConfig struct { - OrgID string `json:"org_id"` - AgentID string `json:"agent_id"` - KID string `json:"kid"` - BaseURL string `json:"base_url"` - Token string `json:"token"` - AgentName string `json:"agent_name"` +// ManagesGuardRuntime reports that Droid commits its guard, audit runtime, +// credentials, and hook documents in one transaction. +func (p *DroidPlugin) ManagesGuardRuntime() bool { + return true } -func (p *DroidPlugin) Install(config InstallConfig) error { - runtimeRoot, agentDirectory, err := droidAgentDirectory(config.AgentID) - if err != nil { - return err - } +// PreflightInstall validates every hook source, policy layer, credential, +// matcher, and runtime identity before any write. +func (p *DroidPlugin) PreflightInstall(config InstallConfig) error { sources, err := readDroidSources() if err != nil { return err } - settings := []droidHookSettings{sources.settings.hooks} - if sources.primary != nil { - settings = append(settings, sources.primary.hooks) - } - if err := validateDroidRegexes(settings...); err != nil { - return err - } - expectedGuard := filepath.Join(agentDirectory, droidGuardScript) - if !sameDroidPath(config.GuardScriptPath, expectedGuard) { - return fmt.Errorf("Elydora guard runtime must use the managed agent directory: %s", expectedGuard) - } - guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime") - if err != nil { - return err - } - if !guardExists { - return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath) - } - nodePath, err := resolveNodeRuntime() - if err != nil { - return err - } - auditPath := filepath.Join(agentDirectory, droidAuditScript) - selection, err := selectDroidInstallationTargets(sources) - if err != nil { - return err - } + _, _, err = preflightDroidInstallation(config, sources) + return err +} + +func renderDroidInstallation( + sources *droidSources, + guardPath, auditPath, nodePath, runtimeRoot string, +) ([]*droidRenderedDocument, error) { + target := activeDroidDocument(sources) groups := map[string]map[string]any{ - "PreToolUse": buildDroidGroup(nodePath, config.GuardScriptPath), + "PreToolUse": buildDroidGroup(nodePath, guardPath), "PostToolUse": buildDroidGroup(nodePath, auditPath), } - documents := uniqueDroidDocuments( - sources.primary, - droidSettingsDocument(sources.settings), - selection.targets["PreToolUse"], - selection.targets["PostToolUse"], - ) + documents := droidInstallationDocuments(sources) rendered := make([]*droidRenderedDocument, 0, len(documents)) for _, document := range documents { - result, renderErr := renderDroidDocument( - document, "", runtimeRoot, droidAdditionsFor(document, selection.targets, groups), + item, err := renderDroidDocument( + document, + "", + runtimeRoot, + droidAdditionsFor(document, target, groups), ) - if renderErr != nil { - return renderErr + if err != nil { + return nil, err } - rendered = append(rendered, result) + rendered = append(rendered, item) } - changes, err := prepareDroidInstallationChanges(config, agentDirectory, auditPath, rendered) + return rendered, nil +} + +func (p *DroidPlugin) Install(config InstallConfig) error { + sources, err := readDroidSources() if err != nil { return err } - if err := writeChanges(changes, "Write Factory Droid installation", p.rename); err != nil { + paths, nodePath, err := preflightDroidInstallation(config, sources) + if err != nil { return err } - fmt.Printf( - " Factory Droid: PreToolUse: %s, PostToolUse: %s\n", - selection.targets["PreToolUse"].filePath, - selection.targets["PostToolUse"].filePath, + rendered, err := renderDroidInstallation( + sources, + paths.guardPath, + paths.auditPath, + nodePath, + paths.runtimeRoot, ) - fmt.Println(" Factory Droid: run /hooks to review the Elydora hook changes.") + if err != nil { + return err + } + prepared, err := prepareDroidInstallation(config, sources, rendered) + if err != nil { + return err + } + if err := commitDroidInstallation(prepared, p.rename); err != nil { + return err + } + fmt.Printf("Factory Droid hooks: %s\n", activeDroidDocument(sources).filePath) + fmt.Println("Factory Droid: run /hooks to review the Elydora hook changes.") return nil } +func renderDroidUninstall( + sources *droidSources, + agentID, runtimeRoot string, +) ([]*droidRenderedDocument, error) { + documents := droidSourceDocuments(sources) + rendered := make([]*droidRenderedDocument, 0, len(documents)) + for _, document := range documents { + item, err := renderDroidDocument( + document, + agentID, + runtimeRoot, + map[string]map[string]any{}, + ) + if err != nil { + return nil, err + } + rendered = append(rendered, item) + } + return rendered, nil +} + func (p *DroidPlugin) Uninstall(agentID string) error { + sources, err := readDroidSources() + if err != nil { + return err + } runtimeRoot, err := droidRuntimeRoot() if err != nil { return err } - sources, err := readDroidSources() + rendered, err := renderDroidUninstall(sources, agentID, runtimeRoot) if err != nil { return err } - documents := uniqueDroidDocuments(sources.primary, droidSettingsDocument(sources.settings)) - changes := make([]*fileChange, 0, len(documents)) - for _, document := range documents { - rendered, renderErr := renderDroidDocument(document, agentID, runtimeRoot, nil) - if renderErr != nil { - return renderErr - } - change, changeErr := prepareRenderedDroidChange(rendered) - if changeErr != nil { - return changeErr - } - changes = append(changes, change) + prepared, err := prepareDroidUninstall(rendered) + if err != nil { + return err } - return writeChanges(changes, "Write Factory Droid hook sources", p.rename) + return commitDroidUninstall(prepared, p.rename) } func (p *DroidPlugin) Status() (PluginStatus, error) { entry := SupportedAgents[droidAgentKey] - status := PluginStatus{ - AgentName: droidAgentKey, DisplayName: entry.Name, - } + status := PluginStatus{AgentName: droidAgentKey, DisplayName: entry.Name} sources, err := readDroidSources() if err != nil { return status, err } status.ConfigPath = displayDroidConfigPath(sources) - primary := droidHookSettings{} - if sources.primary != nil { - primary = sources.primary.hooks - } - effective := mergeDroidSettings(primary, sources.settings.hooks) - if disabled, _ := effective["hooksDisabled"].(bool); disabled { + if droidHookBlocked(sources) != nil { return status, nil } runtimeRoot, err := droidRuntimeRoot() if err != nil { return status, err } - contracts := droidRuntimeContracts(effective, runtimeRoot) + contracts := droidRuntimeContracts(effectiveDroidHooks(sources), runtimeRoot) status.HookConfigured = len(contracts) > 0 if !status.HookConfigured { return status, nil @@ -151,66 +149,3 @@ func (p *DroidPlugin) Status() (PluginStatus, error) { status.Installed = status.HookScriptExists return status, nil } - -func droidAgentDirectory(agentID string) (string, string, error) { - runtimeRoot, err := droidRuntimeRoot() - if err != nil { - return "", "", err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(agentID) - if err != nil { - return "", "", err - } - return runtimeRoot, agentDirectory, nil -} - -func droidSettingsDocument(settings *droidDocument) *droidDocument { - if settings != nil && settings.hasHooksContainer { - return settings - } - return nil -} - -func prepareDroidInstallationChanges( - config InstallConfig, - agentDirectory, auditPath string, - rendered []*droidRenderedDocument, -) ([]*fileChange, error) { - baseURL := config.BaseURL - if baseURL == "" { - baseURL = "https://api.elydora.com" - } - runtimeConfig, err := json.MarshalIndent(droidRuntimeConfig{ - OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID, - BaseURL: baseURL, Token: config.Token, AgentName: droidAgentKey, - }, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - runtimeConfig = append(runtimeConfig, '\n') - runtimeChanges := []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(droidAgentKey, config.AgentID)), 0700}, - } - changes := make([]*fileChange, 0, len(runtimeChanges)+len(rendered)) - for _, item := range runtimeChanges { - 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 := prepareRenderedDroidChange(document) - if changeErr != nil { - return nil, changeErr - } - changes = append(changes, change) - } - return changes, nil -} diff --git a/cmd/elydora/plugins/droid_config.go b/cmd/elydora/plugins/droid_config.go index 45a0073..1b5f13f 100644 --- a/cmd/elydora/plugins/droid_config.go +++ b/cmd/elydora/plugins/droid_config.go @@ -3,8 +3,8 @@ package plugins import ( "bytes" "fmt" - "os" "path/filepath" + "reflect" "sort" ) @@ -13,21 +13,22 @@ type droidDocument struct { filePath string exists bool raw []byte + snapshot *managedFileSnapshot + root map[string]any hooks droidHookSettings basePath []any hasHooksContainer bool + hooksDisabled *bool + showHookOutput *bool ownedFile bool } type droidSources struct { - rootPath string - primary *droidDocument - settings *droidDocument -} - -type droidInstallationTargets struct { - targets map[string]*droidDocument - createdRoot *droidDocument + root *droidDocument + legacy *droidDocument + settings *droidDocument + localSettings *droidDocument + policy *droidPolicyState } type droidRenderedDocument struct { @@ -37,72 +38,39 @@ type droidRenderedDocument struct { remove bool } -func droidFactoryPaths() (string, string, string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", "", "", fmt.Errorf("resolve home directory: %w", err) - } - directory := filepath.Join(home, ".factory") - return filepath.Join(directory, "hooks.json"), - filepath.Join(directory, "hooks", "hooks.json"), - filepath.Join(directory, "settings.json"), nil +type droidHookBlock struct { + field string + filePath string + label string } -func readDroidSources() (*droidSources, error) { - rootPath, legacyPath, settingsPath, err := droidFactoryPaths() - if err != nil { - return nil, err - } - rootRaw, rootExists, err := readOptionalFile(rootPath, "Factory Droid hooks") - if err != nil { - return nil, err - } - var primary *droidDocument - if rootExists { - primary, err = parseDroidDocument(true, rootPath, "hooks", rootRaw) - if err != nil { - return nil, err - } - } else { - legacyRaw, legacyExists, readErr := readOptionalFile(legacyPath, "Factory Droid legacy hooks") - if readErr != nil { - return nil, readErr - } - if legacyExists { - primary, err = parseDroidDocument(true, legacyPath, "legacy", legacyRaw) - if err != nil { - return nil, err - } - } - } - settingsRaw, settingsExists, err := readOptionalFile(settingsPath, "Factory Droid settings") - if err != nil { - return nil, err - } - var settings *droidDocument - if settingsExists { - settings, err = parseDroidDocument(true, settingsPath, "settings", settingsRaw) - } else { - settings, err = parseDroidDocument(false, settingsPath, "settings", []byte("{}\n")) - } - if err != nil { - return nil, err - } - return &droidSources{rootPath: rootPath, primary: primary, settings: settings}, nil -} - -func parseDroidDocument(exists bool, filePath, kind string, raw []byte) (*droidDocument, error) { +func parseDroidDocument( + exists bool, + filePath, kind string, + raw []byte, + snapshot *managedFileSnapshot, +) (*droidDocument, error) { label := droidDocumentLabel(kind, filePath) root, err := decodeJSONCObject(raw, label, true) if err != nil { return nil, err } document := &droidDocument{ - kind: kind, filePath: filePath, exists: exists, raw: append([]byte(nil), raw...), + kind: kind, filePath: filePath, exists: exists, + raw: append([]byte(nil), raw...), snapshot: snapshot, root: root, + ownedFile: bytes.HasPrefix(raw, []byte(droidOwnedFileMarker)), } - if kind == "settings" { - hooksValue, hasHooks := root["hooks"] + if kind == "settings" || kind == "local-settings" { document.basePath = []any{"hooks"} + document.hooksDisabled, err = droidOptionalBoolean(root, "hooksDisabled", label) + if err != nil { + return nil, err + } + document.showHookOutput, err = droidOptionalBoolean(root, "showHookOutput", label) + if err != nil { + return nil, err + } + hooksValue, hasHooks := root["hooks"] document.hasHooksContainer = hasHooks if !hasHooks { document.hooks = droidHookSettings{} @@ -111,16 +79,51 @@ func parseDroidDocument(exists bool, filePath, kind string, raw []byte) (*droidD document.hooks, err = readDroidHookSettings(hooksValue, label+` field "hooks"`) return document, err } - document.hooks, err = readDroidHookSettings(root, label) - document.hasHooksContainer = true - document.ownedFile = bytes.HasPrefix(raw, []byte(droidOwnedFileMarker)) + if hooksValue, hasHooks := root["hooks"]; hasHooks { + document.basePath = []any{"hooks"} + document.hasHooksContainer = true + document.hooks, err = readDroidHookSettings(hooksValue, label+` field "hooks"`) + return document, err + } + document.hooksDisabled, err = droidOptionalBoolean(root, "hooksDisabled", label) + if err != nil { + return nil, err + } + document.showHookOutput, err = droidOptionalBoolean(root, "showHookOutput", label) + if err != nil { + return nil, err + } + direct := make(map[string]any, len(root)) + for key, value := range root { + if key != "hooksDisabled" && key != "showHookOutput" { + direct[key] = value + } + } + document.hooks, err = readDroidHookSettings(direct, label) return document, err } +func droidOptionalBoolean( + root map[string]any, + field, label string, +) (*bool, error) { + value, exists := root[field] + if !exists { + return nil, nil + } + boolean, ok := value.(bool) + if !ok { + return nil, fmt.Errorf(`%s field %q must be a boolean`, label, field) + } + return &boolean, nil +} + func droidDocumentLabel(kind, filePath string) string { switch kind { case "settings": return fmt.Sprintf("Factory Droid settings at %s", filePath) + case "local-settings": + return fmt.Sprintf("Factory Droid local settings at %s", filePath) case "legacy": return fmt.Sprintf("Factory Droid legacy hooks at %s", filePath) default: @@ -128,41 +131,91 @@ func droidDocumentLabel(kind, filePath string) string { } } +func createDroidDocument(filePath, kind string, raw []byte) (*droidDocument, error) { + return parseDroidDocument(false, filePath, kind, raw, nil) +} + func createOwnedDroidDocument(filePath string) (*droidDocument, error) { - return parseDroidDocument( - false, filePath, "hooks", []byte(droidOwnedFileMarker+"\n{}\n"), + return createDroidDocument( + filePath, + "hooks", + []byte(droidOwnedFileMarker+"\n{\n \"hooks\": {}\n}\n"), ) } -func selectDroidInstallationTargets(sources *droidSources) (*droidInstallationTargets, error) { - selection := &droidInstallationTargets{targets: make(map[string]*droidDocument, len(droidToolEvents))} - for _, event := range droidToolEvents { - switch { - case sources.primary != nil && hasDroidHookField(sources.primary.hooks, event): - selection.targets[event] = sources.primary - case sources.settings.hasHooksContainer && hasDroidHookField(sources.settings.hooks, event): - selection.targets[event] = sources.settings - case sources.primary != nil: - selection.targets[event] = sources.primary - case sources.settings.hasHooksContainer: - selection.targets[event] = sources.settings - default: - if selection.createdRoot == nil { - created, err := createOwnedDroidDocument(sources.rootPath) - if err != nil { - return nil, err - } - selection.createdRoot = created - } - selection.targets[event] = selection.createdRoot - } +func activeDroidDocument(sources *droidSources) *droidDocument { + switch { + case sources.root.exists: + return sources.root + case sources.legacy.exists: + return sources.legacy + case sources.localSettings.hasHooksContainer: + return sources.localSettings + case sources.settings.hasHooksContainer: + return sources.settings + default: + return sources.root } - return selection, nil } -func hasDroidHookField(settings droidHookSettings, key string) bool { - _, exists := settings[key] - return exists +func effectiveDroidHooks(sources *droidSources) droidHookSettings { + return activeDroidDocument(sources).hooks +} + +func droidHookBlocked(sources *droidSources) *droidHookBlock { + if sources.policy != nil && sources.policy.allowManagedHooksOnlyBy != nil { + origin := sources.policy.allowManagedHooksOnlyBy + return &droidHookBlock{"allowManagedHooksOnly", origin.filePath, origin.label} + } + if sources.policy != nil && sources.policy.hooksDisabled != nil { + if *sources.policy.hooksDisabled { + origin := sources.policy.hooksDisabledBy + return &droidHookBlock{"hooksDisabled", origin.filePath, origin.label} + } + return nil + } + selected := sources.settings + if sources.localSettings.hooksDisabled != nil { + selected = sources.localSettings + } + if selected.hooksDisabled != nil && *selected.hooksDisabled { + return &droidHookBlock{ + "hooksDisabled", selected.filePath, + droidDocumentLabel(selected.kind, selected.filePath), + } + } + active := activeDroidDocument(sources) + if active.hooksDisabled != nil && *active.hooksDisabled { + return &droidHookBlock{ + "hooksDisabled", active.filePath, + droidDocumentLabel(active.kind, active.filePath), + } + } + return nil +} + +func droidSourceDocuments(sources *droidSources) []*droidDocument { + return []*droidDocument{ + sources.root, sources.legacy, sources.settings, sources.localSettings, + } +} + +func droidInstallationDocuments(sources *droidSources) []*droidDocument { + target := activeDroidDocument(sources) + candidates := []*droidDocument{target} + if sources.root.exists { + candidates = append(candidates, sources.root) + } + if sources.legacy.exists { + candidates = append(candidates, sources.legacy) + } + if sources.settings.hasHooksContainer { + candidates = append(candidates, sources.settings) + } + if sources.localSettings.hasHooksContainer { + candidates = append(candidates, sources.localSettings) + } + return uniqueDroidDocuments(candidates...) } func uniqueDroidDocuments(documents ...*droidDocument) []*droidDocument { @@ -183,17 +236,13 @@ func uniqueDroidDocuments(documents ...*droidDocument) []*droidDocument { } func droidAdditionsFor( - document *droidDocument, - targets map[string]*droidDocument, + document, target *droidDocument, groups map[string]map[string]any, ) map[string]map[string]any { - additions := map[string]map[string]any{} - for _, event := range droidToolEvents { - if sameDroidPath(targets[event].filePath, document.filePath) { - additions[event] = groups[event] - } + if sameDroidPath(document.filePath, target.filePath) { + return groups } - return additions + return map[string]map[string]any{} } func renderDroidDocument( @@ -201,7 +250,14 @@ func renderDroidDocument( agentID, runtimeRoot string, additions map[string]map[string]any, ) (*droidRenderedDocument, error) { - editor, err := newJSONCEditor(document.raw, droidDocumentLabel(document.kind, document.filePath), true) + if len(additions) > 0 && droidHasExactInstallation(document, additions, runtimeRoot) { + return &droidRenderedDocument{document: document, next: document.raw}, nil + } + editor, err := newJSONCEditor( + document.raw, + droidDocumentLabel(document.kind, document.filePath), + true, + ) if err != nil { return nil, err } @@ -212,28 +268,33 @@ func renderDroidDocument( } return removals[left].event < removals[right].event }) - for _, removal := range removals { - groupPath := appendDroidPath(document.basePath, removal.event, removal.groupIndex) - if removal.removeGroup { - if err := editor.remove(groupPath); err != nil { - return nil, err + for _, event := range droidToolEvents { + removedEvent := false + for _, removal := range removals { + if removal.event != event { + continue } - continue - } - sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes))) - for _, handlerIndex := range removal.handlerIndexes { - if err := editor.remove(appendDroidPath(groupPath, "hooks", handlerIndex)); err != nil { - return nil, err + removedEvent = true + groupPath := appendDroidPath(document.basePath, removal.event, removal.groupIndex) + if removal.removeGroup { + if err := editor.remove(groupPath); err != nil { + return nil, err + } + continue + } + sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes))) + for _, handlerIndex := range removal.handlerIndexes { + if err := editor.remove(appendDroidPath(groupPath, "hooks", handlerIndex)); err != nil { + return nil, err + } } } - } - current, err := parseDroidDocument(document.exists, document.filePath, document.kind, editor.pack()) - if err != nil { - return nil, err - } - if document.ownedFile { - for _, event := range droidToolEvents { - groups, exists := current.hooks[event].([]any) + if removedEvent { + current, parseErr := droidHooksFromRaw(document, editor.pack()) + if parseErr != nil { + return nil, parseErr + } + groups, exists := current[event].([]any) if exists && len(groups) == 0 { if err := editor.remove(appendDroidPath(document.basePath, event)); err != nil { return nil, err @@ -246,12 +307,12 @@ func renderDroidDocument( if !exists { continue } - current, err = parseDroidDocument(document.exists, document.filePath, document.kind, editor.pack()) - if err != nil { - return nil, err + current, parseErr := droidHooksFromRaw(document, editor.pack()) + if parseErr != nil { + return nil, parseErr } eventPath := appendDroidPath(document.basePath, event) - if hasDroidHookField(current.hooks, event) { + if _, exists := current[event]; exists { err = editor.appendArray(eventPath, group) } else { err = editor.addProperty(document.basePath, event, []any{group}) @@ -261,16 +322,77 @@ func renderDroidDocument( } } next := editor.pack() - nextDocument, err := parseDroidDocument(document.exists, document.filePath, document.kind, next) + nextDocument, err := parseDroidDocument( + document.exists, document.filePath, document.kind, next, document.snapshot, + ) if err != nil { return nil, err } - remove := len(additions) == 0 && document.ownedFile && document.kind != "settings" && len(nextDocument.hooks) == 0 + remove := len(additions) == 0 && document.exists && document.ownedFile && + droidHookDocumentEmpty(nextDocument) return &droidRenderedDocument{ - document: document, changed: remove || !bytes.Equal(next, document.raw), next: next, remove: remove, + document: document, + changed: remove || !bytes.Equal(next, document.raw), + next: next, + remove: remove, }, nil } +func droidHasExactInstallation( + document *droidDocument, + additions map[string]map[string]any, + runtimeRoot string, +) bool { + if len(additions) != len(droidToolEvents) { + return false + } + removals := managedDroidRemovals(document.hooks, "", runtimeRoot) + if len(removals) != len(droidToolEvents) { + return false + } + for _, event := range droidToolEvents { + matches := make([]droidManagedRemoval, 0, 1) + for _, removal := range removals { + if removal.event == event { + matches = append(matches, removal) + } + } + if len(matches) != 1 || !matches[0].removeGroup { + return false + } + groups := document.hooks[event].([]any) + if !reflect.DeepEqual(groups[matches[0].groupIndex], additions[event]) { + return false + } + } + return true +} + +func droidHooksFromRaw( + document *droidDocument, + raw []byte, +) (droidHookSettings, error) { + current, err := parseDroidDocument( + document.exists, document.filePath, document.kind, raw, document.snapshot, + ) + if err != nil { + return nil, err + } + return current.hooks, nil +} + +func droidHookDocumentEmpty(document *droidDocument) bool { + if document.kind == "settings" || document.kind == "local-settings" || len(document.hooks) > 0 { + return false + } + for field := range document.root { + if field != "hooks" { + return false + } + } + return true +} + func appendDroidPath(base []any, parts ...any) []any { path := make([]any, 0, len(base)+len(parts)) path = append(path, base...) @@ -278,11 +400,5 @@ func appendDroidPath(base []any, parts ...any) []any { } func displayDroidConfigPath(sources *droidSources) string { - if sources.primary != nil { - return sources.primary.filePath - } - if sources.settings.exists && sources.settings.hasHooksContainer { - return sources.settings.filePath - } - return sources.rootPath + return activeDroidDocument(sources).filePath } diff --git a/cmd/elydora/plugins/droid_contract.go b/cmd/elydora/plugins/droid_contract.go index a9fd8cd..c493065 100644 --- a/cmd/elydora/plugins/droid_contract.go +++ b/cmd/elydora/plugins/droid_contract.go @@ -6,33 +6,26 @@ import ( "encoding/json" "fmt" "math" - "os" "os/exec" "path/filepath" "runtime" + "sort" "strings" "time" ) const ( - droidAgentKey = "droid" - droidGuardScript = "guard.js" - droidAuditScript = "hook.js" - droidHookTimeout = float64(10) - droidOwnedFileMarker = "// Managed by Elydora" - droidPOSIXApostrophe = `'"'"'` + droidAgentKey = "droid" + droidGuardScript = "guard.js" + droidAuditScript = "hook.js" + droidHookTimeout = float64(10) + droidOwnedFileMarker = "// Managed by Elydora" + droidPOSIXApostrophe = `'"'"'` + droidWindowsExitSuffix = "; exit $LASTEXITCODE" ) var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"} -var droidEventNames = map[string]bool{ - "PreToolUse": true, "PostToolUse": true, "Notification": true, - "UserPromptSubmit": true, "Stop": true, "SubagentStop": true, - "PreCompact": true, "SessionStart": true, "SessionEnd": true, -} - -var droidFlagNames = map[string]bool{"hooksDisabled": true, "showHookOutput": true} - type droidHookSettings map[string]any type droidManagedRemoval struct { @@ -58,30 +51,20 @@ func readDroidHookSettings(value any, label string) (droidHookSettings, error) { if !ok || object == nil { return nil, fmt.Errorf("%s must contain a JSON object", label) } - settings := make(droidHookSettings, len(object)) - for key, item := range object { - if droidFlagNames[key] { - if _, ok := item.(bool); !ok { - return nil, fmt.Errorf(`%s field %q must be a boolean`, label, key) - } - settings[key] = item - continue - } - if !droidEventNames[key] { - return nil, fmt.Errorf(`%s contains unsupported field %q`, label, key) - } + hooks := make(droidHookSettings, len(object)) + for event, item := range object { groups, ok := item.([]any) if !ok { - return nil, fmt.Errorf(`%s field %q must be an array`, label, key) + return nil, fmt.Errorf(`%s field %q must be an array`, label, event) } for groupIndex, group := range groups { - if err := validateDroidGroup(group, label, key, groupIndex); err != nil { + if err := validateDroidGroup(group, label, event, groupIndex); err != nil { return nil, err } } - settings[key] = item + hooks[event] = item } - return settings, nil + return hooks, nil } func validateDroidGroup(value any, label, event string, groupIndex int) error { @@ -121,13 +104,14 @@ func validateDroidHandler(value any, groupLabel string, handlerIndex int) error if handler["type"] != "command" { return fmt.Errorf(`%s type must be "command"`, location) } - if _, ok := handler["command"].(string); !ok { - return fmt.Errorf("%s command must be a string", location) + command, ok := handler["command"].(string) + if !ok || strings.TrimSpace(command) == "" { + return fmt.Errorf("%s command must be a non-empty string", location) } if timeout, exists := handler["timeout"]; exists { number, ok := timeout.(float64) - if !ok || math.IsNaN(number) || math.IsInf(number, 0) { - return fmt.Errorf("%s timeout must be a finite number", location) + if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 { + return fmt.Errorf("%s timeout must be a positive finite number", location) } } return nil @@ -137,19 +121,19 @@ func validateDroidRegexes(settings ...droidHookSettings) error { entries := make([]droidRegexEntry, 0) for _, source := range settings { for event, value := range source { - if droidFlagNames[event] { - continue - } - for index, groupValue := range value.([]any) { + groups, _ := value.([]any) + for index, groupValue := range groups { group := groupValue.(map[string]any) if matcher, ok := group["matcher"].(string); ok && matcher != "" && matcher != "*" { entries = append(entries, droidRegexEntry{ - Label: fmt.Sprintf(`Factory Droid hooks field %q[%d] matcher`, event, index), Pattern: matcher, + Label: fmt.Sprintf(`Factory Droid hooks field %q[%d] matcher`, event, index), + Pattern: matcher, }) } if expression, ok := group["commandRegex"].(string); ok { entries = append(entries, droidRegexEntry{ - Label: fmt.Sprintf(`Factory Droid hooks field %q[%d] commandRegex`, event, index), Pattern: expression, + Label: fmt.Sprintf(`Factory Droid hooks field %q[%d] commandRegex`, event, index), + Pattern: expression, }) } } @@ -196,17 +180,14 @@ for (const entry of entries) { func buildDroidCommand(nodePath, scriptPath string) string { if runtime.GOOS == "windows" { - return quoteDroidWindowsArgument(nodePath) + " " + quoteDroidWindowsArgument(scriptPath) + return "& " + quoteDroidPowerShellArgument(nodePath) + " " + + quoteDroidPowerShellArgument(scriptPath) + droidWindowsExitSuffix } return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) } -func quoteDroidWindowsArgument(value string) string { - quoted := quoteWindowsArgument(value) - if strings.HasPrefix(quoted, `"`) { - return quoted - } - return `"` + quoted + `"` +func quoteDroidPowerShellArgument(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" } func buildDroidGroup(nodePath, scriptPath string) map[string]any { @@ -218,38 +199,80 @@ func buildDroidGroup(nodePath, scriptPath string) map[string]any { } } -func parseDroidCommand(command string) (string, string, bool) { - reader := readDroidPOSIXArgument - if runtime.GOOS == "windows" { - reader = readDroidWindowsArgument +func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) { + if runtime.GOOS != "windows" { + return parseDroidPOSIXCommand(command) } - executable, next, ok := reader(command, 0) + executable, script, ok := parseDroidPowerShellCommand(command) + if ok || len(includeLegacy) == 0 || !includeLegacy[0] { + return executable, script, ok + } + return parseDroidLegacyWindowsCommand(command) +} + +func parseDroidPOSIXCommand(command string) (string, string, bool) { + executable, next, ok := readDroidPOSIXArgument(command, 0) if !ok || next >= len(command) || command[next] != ' ' { return "", "", false } - script, end, ok := reader(command, next+1) + script, end, ok := readDroidPOSIXArgument(command, next+1) return executable, script, ok && end == len(command) && executable != "" && script != "" } -func readDroidWindowsArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '"' { +func parseDroidPowerShellCommand(command string) (string, string, bool) { + if !strings.HasPrefix(command, "& ") { + return "", "", false + } + executable, next, ok := readDroidPowerShellArgument(command, 2) + if !ok || next >= len(command) || command[next] != ' ' { + return "", "", false + } + script, end, ok := readDroidPowerShellArgument(command, next+1) + return executable, script, ok && command[end:] == droidWindowsExitSuffix && executable != "" && script != "" +} + +func parseDroidLegacyWindowsCommand(command string) (string, string, bool) { + executable, next, ok := readDroidLegacyWindowsArgument(command, 0) + if !ok || next >= len(command) || command[next] != ' ' { + return "", "", false + } + script, end, ok := readDroidLegacyWindowsArgument(command, next+1) + return executable, script, ok && end == len(command) && executable != "" && script != "" +} + +func readDroidPowerShellArgument(command string, start int) (string, int, bool) { + if start >= len(command) || command[start] != '\'' { return "", start, false } var value strings.Builder for index := start + 1; index < len(command); index++ { - if command[index] == '\\' && index+1 < len(command) && command[index+1] == '"' { - value.WriteByte('"') + if command[index] != '\'' { + value.WriteByte(command[index]) + continue + } + if index+1 < len(command) && command[index+1] == '\'' { + value.WriteByte('\'') index++ continue } - if command[index] == '"' { - return value.String(), index + 1, true - } - value.WriteByte(command[index]) + return value.String(), index + 1, true } return "", start, false } +func readDroidLegacyWindowsArgument(command string, start int) (string, int, bool) { + if start >= len(command) || command[start] != '"' { + return "", start, false + } + end := strings.IndexByte(command[start+1:], '"') + if end < 0 { + return "", start, false + } + end += start + 1 + value := command[start+1 : end] + return value, end + 1, value != "" && !strings.ContainsAny(value, "\r\n") +} + func readDroidPOSIXArgument(command string, start int) (string, int, bool) { if start >= len(command) || command[start] != '\'' { return "", start, false @@ -293,14 +316,14 @@ func sameDroidAgentID(left, right string) bool { } func droidRuntimeRoot() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) - } - return filepath.Join(home, ".elydora"), nil + return AgentRuntimeRoot() } -func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot string) (string, bool) { +func managedDroidAgentID( + handler map[string]any, + scriptName, runtimeRoot string, + includeLegacy ...bool, +) (string, bool) { if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != droidHookTimeout { return "", false } @@ -308,9 +331,9 @@ func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot string) if !ok { return "", false } - executable, scriptPath, ok := parseDroidCommand(command) - if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) || !isDroidNodeExecutable(executable) || - !sameDroidFileName(filepath.Base(scriptPath), scriptName) { + executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...) + if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) || + !isDroidNodeExecutable(executable) || !sameDroidFileName(filepath.Base(scriptPath), scriptName) { return "", false } agentDirectory := filepath.Dir(scriptPath) @@ -323,10 +346,7 @@ func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot string) func isDroidNodeExecutable(path string) bool { name := filepath.Base(path) - if runtime.GOOS == "windows" { - return strings.EqualFold(name, "node.exe") - } - return name == "node" + return name == "node" || strings.EqualFold(name, "node.exe") } func sameDroidFileName(left, right string) bool { @@ -347,7 +367,9 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin handlers := group["hooks"].([]any) indexes := make([]int, 0) for handlerIndex, handlerValue := range handlers { - managedID, managed := managedDroidAgentID(handlerValue.(map[string]any), contract.script, runtimeRoot) + managedID, managed := managedDroidAgentID( + handlerValue.(map[string]any), contract.script, runtimeRoot, true, + ) if managed && (agentID == "" || sameDroidAgentID(managedID, agentID)) { indexes = append(indexes, handlerIndex) } @@ -357,8 +379,11 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin } _, hasMatcher := group["matcher"] _, hasHooks := group["hooks"] - exactGroup := len(group) == 2 && hasMatcher && hasHooks && group["matcher"] == "*" && len(indexes) == len(handlers) - removals = append(removals, droidManagedRemoval{contract.event, groupIndex, indexes, exactGroup}) + exactGroup := len(group) == 2 && hasMatcher && hasHooks && + group["matcher"] == "*" && len(indexes) == len(handlers) + removals = append(removals, droidManagedRemoval{ + contract.event, groupIndex, indexes, exactGroup, + }) } } return removals @@ -367,20 +392,24 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []droidRuntimeContract { guards := managedDroidIDs(settings, "PreToolUse", droidGuardScript, runtimeRoot) audits := managedDroidIDs(settings, "PostToolUse", droidAuditScript, runtimeRoot) - contracts := make([]droidRuntimeContract, 0) - for guardID := range guards { - matched := false + guardIDs := make([]string, 0, len(guards)) + for agentID := range guards { + guardIDs = append(guardIDs, agentID) + } + sort.Strings(guardIDs) + contracts := make([]droidRuntimeContract, 0, len(guardIDs)) + for _, guardID := range guardIDs { for auditID := range audits { - if sameDroidAgentID(guardID, auditID) { - matched = true - break + if !sameDroidAgentID(guardID, auditID) { + continue } - } - if matched { root := filepath.Join(runtimeRoot, guardID) contracts = append(contracts, droidRuntimeContract{ - guardID, filepath.Join(root, droidGuardScript), filepath.Join(root, droidAuditScript), + guardID, + filepath.Join(root, droidGuardScript), + filepath.Join(root, droidAuditScript), }) + break } } return contracts @@ -390,22 +419,14 @@ func managedDroidIDs(settings droidHookSettings, event, script, runtimeRoot stri ids := map[string]bool{} groups, _ := settings[event].([]any) for _, groupValue := range groups { - for _, handlerValue := range groupValue.(map[string]any)["hooks"].([]any) { - if agentID, ok := managedDroidAgentID(handlerValue.(map[string]any), script, runtimeRoot); ok { - ids[agentID] = true - } + group := groupValue.(map[string]any) + handlers := group["hooks"].([]any) + if len(group) != 2 || group["matcher"] != "*" || len(handlers) != 1 { + continue + } + if agentID, ok := managedDroidAgentID(handlers[0].(map[string]any), script, runtimeRoot); ok { + ids[agentID] = true } } return ids } - -func mergeDroidSettings(primary, fallback droidHookSettings) droidHookSettings { - merged := make(droidHookSettings, len(primary)+len(fallback)) - for key, value := range fallback { - merged[key] = value - } - for key, value := range primary { - merged[key] = value - } - return merged -} diff --git a/cmd/elydora/plugins/droid_installation.go b/cmd/elydora/plugins/droid_installation.go new file mode 100644 index 0000000..4730cbf --- /dev/null +++ b/cmd/elydora/plugins/droid_installation.go @@ -0,0 +1,400 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type droidRuntimePaths struct { + runtimeRoot string + agentDirectory string + guardPath string + auditPath string +} + +type preparedDroidInstallation struct { + changes []*fileChange + preconditions []filePrecondition + paths *droidRuntimePaths +} + +type preparedDroidUninstall struct { + changes []*fileChange + preconditions []filePrecondition +} + +func droidSourceLabel(document *droidDocument) string { + switch document.kind { + case "settings": + return "Factory Droid user settings" + case "local-settings": + return "Factory Droid local settings" + case "legacy": + return "Factory Droid legacy hooks" + default: + return "Factory Droid user hooks" + } +} + +func validateDroidInstallConfig(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 != droidAgentKey { + return fmt.Errorf( + "Factory Droid installation requires agent name %s", + droidAgentKey, + ) + } + 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 droidAgentPaths(config InstallConfig) (*droidRuntimePaths, error) { + if err := validateDroidInstallConfig(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 := &droidRuntimePaths{ + runtimeRoot: runtimeRoot, + agentDirectory: agentDirectory, + guardPath: filepath.Join(agentDirectory, droidGuardScript), + auditPath: filepath.Join(agentDirectory, droidAuditScript), + } + if !filepath.IsAbs(config.GuardScriptPath) || + !sameDroidPath(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) || + !sameDroidPath(config.HookScript, paths.auditPath)) { + return nil, fmt.Errorf( + "Elydora audit runtime must use the managed agent directory: %s", + paths.auditPath, + ) + } + return paths, nil +} + +func preflightDroidInstallation( + config InstallConfig, + sources *droidSources, +) (*droidRuntimePaths, string, error) { + if sources == nil || sources.root == nil || sources.policy == nil { + return nil, "", fmt.Errorf("Factory Droid installation sources are required") + } + if err := requireDroidHooksEnabled(sources); err != nil { + return nil, "", err + } + hooks := make([]droidHookSettings, 0, len(droidSourceDocuments(sources))) + for _, document := range droidSourceDocuments(sources) { + hooks = append(hooks, document.hooks) + } + if err := validateDroidRegexes(hooks...); err != nil { + return nil, "", err + } + paths, err := droidAgentPaths(config) + if err != nil { + return nil, "", err + } + if err := validateDroidRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + return nil, "", err + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return nil, "", err + } + if !filepath.IsAbs(nodePath) || !isDroidNodeExecutable(nodePath) { + return nil, "", fmt.Errorf( + "Factory Droid hooks require an absolute Node.js executable path", + ) + } + return paths, nodePath, nil +} + +func buildDroidRuntimeConfig(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": droidAgentKey, + } + 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 prepareRenderedDroidChange( + rendered *droidRenderedDocument, +) (*fileChange, error) { + if rendered == nil || !rendered.changed { + return nil, nil + } + return prepareSnapshotSourceChange( + rendered.document.filePath, + droidSourceLabel(rendered.document), + rendered.document.snapshot, + rendered.next, + 0600, + rendered.remove, + ) +} + +func validateDroidRenderedSet( + sources *droidSources, + rendered []*droidRenderedDocument, +) error { + expected := droidInstallationDocuments(sources) + if len(rendered) != len(expected) { + return fmt.Errorf("Factory Droid rendered source set is incomplete") + } + for _, document := range expected { + matches := 0 + for _, item := range rendered { + if item != nil && item.document != nil && + sameDroidPath(item.document.filePath, document.filePath) { + matches++ + } + } + if matches != 1 { + return fmt.Errorf("Factory Droid rendered source set contains unexpected paths") + } + } + return nil +} + +func droidSourcePreconditions( + sources *droidSources, + changedPaths []string, +) []filePrecondition { + preconditions := make([]filePrecondition, 0, len(droidSourceDocuments(sources))) + for _, document := range droidSourceDocuments(sources) { + changed := false + for _, path := range changedPaths { + if sameDroidPath(document.filePath, path) { + changed = true + break + } + } + if !changed { + preconditions = append(preconditions, filePrecondition{ + filePath: document.filePath, + label: droidSourceLabel(document), + snapshot: document.snapshot, + maximumSize: maxManagedSourceBytes, + }) + } + } + return preconditions +} + +func prepareDroidInstallation( + config InstallConfig, + sources *droidSources, + rendered []*droidRenderedDocument, +) (*preparedDroidInstallation, error) { + paths, _, err := preflightDroidInstallation(config, sources) + if err != nil { + return nil, err + } + if err := validateDroidRenderedSet(sources, rendered); err != nil { + return nil, err + } + runtimeConfig, err := buildDroidRuntimeConfig(config) + if err != nil { + return nil, err + } + items := []struct { + path, label string + content []byte + mode os.FileMode + }{ + { + paths.guardPath, + "Elydora guard runtime", + []byte(generateGuardScript(droidAgentKey, config.AgentID, "", false, "")), + 0700, + }, + {filepath.Join(paths.agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600}, + {filepath.Join(paths.agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600}, + { + paths.auditPath, + "Elydora audit runtime", + []byte(buildHookScriptWithOutput(droidAgentKey, 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) + } + changedPaths := make([]string, 0, len(rendered)) + for _, document := range rendered { + change, changeErr := prepareRenderedDroidChange(document) + if changeErr != nil { + return nil, changeErr + } + changes = append(changes, change) + if change != nil { + changedPaths = append(changedPaths, change.filePath) + } + } + preconditions := droidSourcePreconditions(sources, changedPaths) + preconditions = append(preconditions, sources.policy.preconditions...) + return &preparedDroidInstallation{ + changes: changes, + preconditions: preconditions, + paths: paths, + }, nil +} + +func writeDroidChanges( + changes []*fileChange, + label string, + rename renameFunc, + runtimeRoot, agentDirectory string, + preconditions []filePrecondition, +) error { + hasChanges := false + for _, change := range changes { + if change != nil { + hasChanges = true + break + } + } + if !hasChanges { + return writeChanges(changes, label, rename, preconditions...) + } + if err := assertFilePreconditions(preconditions, label); err != nil { + return fmt.Errorf("%s: %w", label, err) + } + if agentDirectory != "" { + if err := EnsurePrivateDirectory(runtimeRoot); err != nil { + return err + } + if err := EnsurePrivateDirectory(agentDirectory); err != nil { + return err + } + } + return writeChanges(changes, label, rename, preconditions...) +} + +func commitDroidInstallation( + prepared *preparedDroidInstallation, + rename renameFunc, +) error { + if prepared == nil || prepared.paths == nil { + return fmt.Errorf("prepared Factory Droid installation is required") + } + return writeDroidChanges( + prepared.changes, + "Install Factory Droid hooks", + rename, + prepared.paths.runtimeRoot, + prepared.paths.agentDirectory, + prepared.preconditions, + ) +} + +func prepareDroidUninstall( + rendered []*droidRenderedDocument, +) (*preparedDroidUninstall, error) { + changes := make([]*fileChange, 0, len(rendered)) + changedPaths := make([]string, 0, len(rendered)) + for _, item := range rendered { + change, err := prepareRenderedDroidChange(item) + if err != nil { + return nil, err + } + changes = append(changes, change) + if change != nil { + changedPaths = append(changedPaths, change.filePath) + } + } + preconditions := make([]filePrecondition, 0, len(rendered)) + for _, item := range rendered { + changed := false + for _, path := range changedPaths { + if sameDroidPath(item.document.filePath, path) { + changed = true + break + } + } + if !changed { + preconditions = append(preconditions, filePrecondition{ + filePath: item.document.filePath, + label: droidSourceLabel(item.document), + snapshot: item.document.snapshot, + maximumSize: maxManagedSourceBytes, + }) + } + } + return &preparedDroidUninstall{changes, preconditions}, nil +} + +func commitDroidUninstall( + prepared *preparedDroidUninstall, + rename renameFunc, +) error { + if prepared == nil { + return fmt.Errorf("prepared Factory Droid uninstall is required") + } + return writeDroidChanges( + prepared.changes, + "Uninstall Factory Droid hooks", + rename, + "", + "", + prepared.preconditions, + ) +} diff --git a/cmd/elydora/plugins/droid_io.go b/cmd/elydora/plugins/droid_io.go index cdbef39..1e94081 100644 --- a/cmd/elydora/plugins/droid_io.go +++ b/cmd/elydora/plugins/droid_io.go @@ -1,62 +1,139 @@ package plugins import ( - "encoding/json" "fmt" "os" "path/filepath" ) -func prepareRenderedDroidChange(rendered *droidRenderedDocument) (*fileChange, error) { - if rendered == nil || !rendered.changed { - return nil, nil +type droidFactoryConfigPaths struct { + directory string + root string + legacyDirectory string + legacy string + settings string + localSettings string +} + +func droidFactoryPaths() (*droidFactoryConfigPaths, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("resolve home directory: %w", err) } - label := "Factory Droid hooks" - if rendered.document.kind == "legacy" { - label = "Factory Droid legacy hooks" - } else if rendered.document.kind == "settings" { - label = "Factory Droid settings" + directory := filepath.Join(home, ".factory") + legacyDirectory := filepath.Join(directory, "hooks") + return &droidFactoryConfigPaths{ + directory: directory, + root: filepath.Join(directory, "hooks.json"), + legacyDirectory: legacyDirectory, + legacy: filepath.Join(legacyDirectory, "hooks.json"), + settings: filepath.Join(directory, "settings.json"), + localSettings: filepath.Join(directory, "settings.local.json"), + }, nil +} + +func readDroidDocument( + filePath, kind, label string, +) (*droidDocument, error) { + snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes) + if err != nil || snapshot == nil { + return nil, err } - return prepareSourceChange( - rendered.document.filePath, - label, - rendered.document.raw, - rendered.document.exists, - rendered.next, - os.FileMode(0600), - rendered.remove, + return parseDroidDocument( + true, + filePath, + kind, + snapshot.contents, + snapshot, ) } -func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) { - for _, contract := range contracts { - configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json") - raw, exists, err := readOptionalFile(configPath, "Elydora runtime config") +func readDroidSources() (*droidSources, error) { + paths, err := droidFactoryPaths() + if err != nil { + return nil, err + } + if _, err := managedPhysicalDirectoryExists( + paths.directory, + "Factory Droid user configuration directory", + ); err != nil { + return nil, err + } + if _, err := managedPhysicalDirectoryExists( + paths.legacyDirectory, + "Factory Droid legacy hooks directory", + ); err != nil { + return nil, err + } + root, err := readDroidDocument(paths.root, "hooks", "Factory Droid user hooks") + if err != nil { + return nil, err + } + if root == nil { + root, err = createOwnedDroidDocument(paths.root) if err != nil { - return false, err - } - if !exists { - continue - } - var config map[string]any - if err := json.Unmarshal(raw, &config); err != nil { - return false, fmt.Errorf("parse Elydora runtime config at %s: %w", configPath, err) - } - agentID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != droidAgentKey || !sameDroidAgentID(agentID, contract.agentID) { - continue - } - guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime") - if err != nil { - return false, err - } - auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime") - if err != nil { - return false, err - } - if guardExists && auditExists { - return true, nil + return nil, err } } - return false, nil + legacy, err := readDroidDocument(paths.legacy, "legacy", "Factory Droid legacy hooks") + if err != nil { + return nil, err + } + if legacy == nil { + legacy, err = createDroidDocument(paths.legacy, "legacy", []byte("{}\n")) + if err != nil { + return nil, err + } + } + settings, err := readDroidDocument(paths.settings, "settings", "Factory Droid user settings") + if err != nil { + return nil, err + } + if settings == nil { + settings, err = createDroidDocument(paths.settings, "settings", []byte("{}\n")) + if err != nil { + return nil, err + } + } + localSettings, err := readDroidDocument( + paths.localSettings, + "local-settings", + "Factory Droid local settings", + ) + if err != nil { + return nil, err + } + if localSettings == nil { + localSettings, err = createDroidDocument( + paths.localSettings, + "local-settings", + []byte("{}\n"), + ) + if err != nil { + return nil, err + } + } + policy, err := readDroidPolicy() + if err != nil { + return nil, err + } + return &droidSources{ + root: root, + legacy: legacy, + settings: settings, + localSettings: localSettings, + policy: policy, + }, nil +} + +func requireDroidHooksEnabled(sources *droidSources) error { + if blocked := droidHookBlocked(sources); blocked != nil { + return fmt.Errorf( + "Factory Droid user hooks are disabled by %s in %s at %s", + blocked.field, + blocked.label, + blocked.filePath, + ) + } + return nil } diff --git a/cmd/elydora/plugins/droid_policy.go b/cmd/elydora/plugins/droid_policy.go new file mode 100644 index 0000000..cd50cfa --- /dev/null +++ b/cmd/elydora/plugins/droid_policy.go @@ -0,0 +1,267 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +type droidPolicyLocation struct { + filePath string + label string +} + +type droidPolicyLayer struct { + filePath string + label string + snapshot *managedFileSnapshot + hooksDisabled *bool + allowManagedHooksOnly *bool + showHookOutput *bool +} + +type droidPolicyOrigin struct { + filePath string + label string +} + +type droidPolicyState struct { + allowManagedHooksOnlyBy *droidPolicyOrigin + hooksDisabled *bool + hooksDisabledBy *droidPolicyOrigin + preconditions []filePrecondition +} + +func defaultDroidManagedSettingsPath() string { + switch runtime.GOOS { + case "darwin": + return "/Library/Application Support/Factory/settings.json" + case "windows": + programFiles := os.Getenv("ProgramFiles") + if programFiles == "" { + programFiles = `C:\Program Files` + } + return filepath.Join(programFiles, "Factory", "settings.json") + default: + return "/etc/factory/settings.json" + } +} + +var droidManagedSettingsPath = defaultDroidManagedSettingsPath + +func readDroidPolicyLayer(location droidPolicyLocation) (*droidPolicyLayer, error) { + if _, err := managedPhysicalDirectoryExists( + filepath.Dir(location.filePath), + location.label+" directory", + ); err != nil { + return nil, err + } + snapshot, err := readManagedFile( + location.filePath, + location.label, + maxManagedSourceBytes, + ) + if err != nil { + return nil, err + } + layer := &droidPolicyLayer{ + filePath: location.filePath, + label: location.label, + snapshot: snapshot, + } + if snapshot == nil { + return layer, nil + } + root, err := decodeJSONCObject( + snapshot.contents, + fmt.Sprintf("%s at %s", location.label, location.filePath), + true, + ) + if err != nil { + return nil, err + } + layer.hooksDisabled, err = droidOptionalBoolean(root, "hooksDisabled", location.label) + if err != nil { + return nil, err + } + layer.allowManagedHooksOnly, err = droidOptionalBoolean( + root, + "allowManagedHooksOnly", + location.label, + ) + if err != nil { + return nil, err + } + layer.showHookOutput, err = droidOptionalBoolean(root, "showHookOutput", location.label) + if err != nil { + return nil, err + } + return layer, nil +} + +func droidGitRoot(start string) (string, bool, error) { + current, err := filepath.Abs(start) + if err != nil { + return "", false, fmt.Errorf("resolve Factory Droid project directory: %w", err) + } + for { + marker := filepath.Join(current, ".git") + info, inspectErr := os.Lstat(marker) + switch { + case inspectErr == nil: + if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) { + return "", false, fmt.Errorf( + "Factory Droid project marker is not physical: %s", + marker, + ) + } + return current, true, nil + case !os.IsNotExist(inspectErr): + return "", false, fmt.Errorf( + "inspect Factory Droid project marker at %s: %w", + marker, + inspectErr, + ) + } + parent := filepath.Dir(current) + if parent == current { + return "", false, nil + } + current = parent + } +} + +func droidProjectDirectories(root, current string) []string { + directories := []string{root} + relative, err := filepath.Rel(root, current) + if err != nil || relative == "." || relative == "" || filepath.IsAbs(relative) { + return directories + } + if relative == ".." || len(relative) > 3 && relative[:3] == ".."+string(os.PathSeparator) { + return directories + } + directory := root + for _, segment := range splitDroidPath(relative) { + directory = filepath.Join(directory, segment) + directories = append(directories, directory) + } + return directories +} + +func splitDroidPath(value string) []string { + parts := make([]string, 0) + for value != "." && value != "" { + directory, file := filepath.Split(value) + if file != "" { + parts = append([]string{file}, parts...) + } + value = filepath.Clean(directory) + if value == string(os.PathSeparator) { + break + } + } + return parts +} + +func droidProjectPolicyLocations() ([][2]droidPolicyLocation, error) { + current, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("resolve Factory Droid working directory: %w", err) + } + current, err = filepath.Abs(current) + if err != nil { + return nil, fmt.Errorf("resolve Factory Droid working directory: %w", err) + } + root, found, err := droidGitRoot(current) + if err != nil { + return nil, err + } + if !found { + root = current + } + directories := droidProjectDirectories(root, current) + locations := make([][2]droidPolicyLocation, 0, len(directories)) + for index, directory := range directories { + scope := "project" + if index > 0 { + scope = "folder " + directory + } + factory := filepath.Join(directory, ".factory") + locations = append(locations, [2]droidPolicyLocation{ + {filepath.Join(factory, "settings.json"), "Factory Droid " + scope + " settings"}, + {filepath.Join(factory, "settings.local.json"), "Factory Droid " + scope + " local settings"}, + }) + } + return locations, nil +} + +func droidScopeHooksDisabled( + settings, local *droidPolicyLayer, +) *droidPolicyLayer { + if local.hooksDisabled != nil { + return local + } + if settings.hooksDisabled != nil { + return settings + } + return nil +} + +func readDroidPolicy() (*droidPolicyState, error) { + managed, err := readDroidPolicyLayer(droidPolicyLocation{ + droidManagedSettingsPath(), + "Factory Droid system-managed settings", + }) + if err != nil { + return nil, err + } + locations, err := droidProjectPolicyLocations() + if err != nil { + return nil, err + } + projectLayers := make([][2]*droidPolicyLayer, 0, len(locations)) + for _, scope := range locations { + settings, readErr := readDroidPolicyLayer(scope[0]) + if readErr != nil { + return nil, readErr + } + local, readErr := readDroidPolicyLayer(scope[1]) + if readErr != nil { + return nil, readErr + } + projectLayers = append(projectLayers, [2]*droidPolicyLayer{settings, local}) + } + state := &droidPolicyState{} + if managed.allowManagedHooksOnly != nil && *managed.allowManagedHooksOnly { + state.allowManagedHooksOnlyBy = &droidPolicyOrigin{managed.filePath, managed.label} + } + selected := managed + if selected.hooksDisabled == nil { + selected = nil + for _, scope := range projectLayers { + if layer := droidScopeHooksDisabled(scope[0], scope[1]); layer != nil { + selected = layer + break + } + } + } + if selected != nil { + state.hooksDisabled = selected.hooksDisabled + state.hooksDisabledBy = &droidPolicyOrigin{selected.filePath, selected.label} + } + layers := []*droidPolicyLayer{managed} + for _, scope := range projectLayers { + layers = append(layers, scope[0], scope[1]) + } + state.preconditions = make([]filePrecondition, 0, len(layers)) + for _, layer := range layers { + state.preconditions = append(state.preconditions, filePrecondition{ + filePath: layer.filePath, + label: layer.label, + snapshot: layer.snapshot, + maximumSize: maxManagedSourceBytes, + }) + } + return state, nil +} diff --git a/cmd/elydora/plugins/droid_policy_test.go b/cmd/elydora/plugins/droid_policy_test.go new file mode 100644 index 0000000..12ce31e --- /dev/null +++ b/cmd/elydora/plugins/droid_policy_test.go @@ -0,0 +1,124 @@ +package plugins + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDroidUserSettingsDisableBeforeRuntimeCreation(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + settings: droidJSON(map[string]any{"hooksDisabled": true}), + }) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "hooksDisabled") { + t.Fatalf("disabled hooks error = %v", err) + } + requireMissingDroidFile(t, fixture.runtimeConfig) + requireMissingDroidFile(t, fixture.configPath) +} + +func TestDroidUserLocalSettingsOverrideBaseSettings(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + settings: droidJSON(map[string]any{"hooksDisabled": true}), + localSettings: droidJSON(map[string]any{"hooksDisabled": false}), + }) + installDroidFixture(t, fixture) + if _, err := os.Stat(fixture.runtimeConfig); err != nil { + t.Fatalf("runtime config is missing: %v", err) + } +} + +func TestDroidLegacyDirectFlagsRemainSafetyCompatible(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + legacy: droidJSON(map[string]any{"hooksDisabled": true, "PreToolUse": []any{}}), + settings: droidJSON(map[string]any{"hooksDisabled": false}), + }) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "legacy hooks") { + t.Fatalf("legacy safety error = %v", err) + } + requireMissingDroidFile(t, fixture.runtimeConfig) +} + +func TestDroidProjectPolicyHasExtensionOnlyPrecedence(t *testing.T) { + t.Run("project blocks user allow", func(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + settings: droidJSON(map[string]any{"hooksDisabled": false}), + projectSettings: droidJSON(map[string]any{"hooksDisabled": true}), + }) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "project settings") { + t.Fatalf("project policy error = %v", err) + } + requireMissingDroidFile(t, fixture.runtimeConfig) + }) + t.Run("project allows through user block", func(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + settings: droidJSON(map[string]any{"hooksDisabled": true}), + projectSettings: droidJSON(map[string]any{"hooksDisabled": false}), + }) + installDroidFixture(t, fixture) + }) +} + +func TestDroidProjectLocalSettingsOverrideMatchingBase(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + projectSettings: droidJSON(map[string]any{"hooksDisabled": false}), + projectLocalSettings: droidJSON(map[string]any{"hooksDisabled": true}), + }) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "project local settings") { + t.Fatalf("project local policy error = %v", err) + } +} + +func TestDroidProjectValuePrecedesDeeperFolderValue(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + projectSettings: droidJSON(map[string]any{"hooksDisabled": false}), + }) + child := filepath.Join(fixture.workspaceDir, "packages", "console") + if err := os.MkdirAll(child, 0755); err != nil { + t.Fatalf("create child workspace: %v", err) + } + writeDroidTestObject( + t, + filepath.Join(child, ".factory", "settings.json"), + map[string]any{"hooksDisabled": true}, + ) + if err := os.Chdir(child); err != nil { + t.Fatalf("enter child workspace: %v", err) + } + installDroidFixture(t, fixture) +} + +func TestDroidSystemManagedPolicyBlocksUserHooks(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + writeDroidTestObject( + t, + fixture.systemSettingsPath, + map[string]any{"allowManagedHooksOnly": true}, + ) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "allowManagedHooksOnly") { + t.Fatalf("managed policy error = %v", err) + } + requireMissingDroidFile(t, fixture.runtimeConfig) +} + +func TestDroidMalformedReadOnlyProjectPolicyIsPreserved(t *testing.T) { + malformed := "{ malformed" + fixture := prepareDroidFixture(t, droidFixtureOptions{ + projectSettings: droidString(malformed), + }) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "project settings") { + t.Fatalf("malformed project policy error = %v", err) + } + if readDroidTestFile(t, fixture.projectSettingsPath) != malformed { + t.Fatal("malformed read-only policy changed") + } + requireMissingDroidFile(t, fixture.runtimeConfig) + requireMissingDroidFile(t, fixture.configPath) +} diff --git a/cmd/elydora/plugins/droid_recovery_test.go b/cmd/elydora/plugins/droid_recovery_test.go index a4612db..a381e50 100644 --- a/cmd/elydora/plugins/droid_recovery_test.go +++ b/cmd/elydora/plugins/droid_recovery_test.go @@ -1,81 +1,101 @@ package plugins import ( - "encoding/json" "errors" - "io/fs" "os" "path/filepath" + "runtime" "strings" "testing" ) -func TestDroidUninstallPreservesMixedGroupsAndLookalikes(t *testing.T) { - fixture := prepareDroidFixture(t, droidFixtureOptions{}) - installDroidFixture(t, fixture) - root := readDroidTestObject(t, fixture.configPath) - group := droidManagedGroup(t, root["PreToolUse"], fixture.guardPath) - command := droidManagedHandler(t, []any{group}, fixture.guardPath)["command"].(string) - group["hooks"] = append(requireDroidArray(t, group["hooks"]), map[string]any{ - "type": "command", "command": "user-command", - }) - group["owner"] = "user" - root["PreToolUse"] = append(requireDroidArray(t, root["PreToolUse"]), - map[string]any{ - "matcher": "*", - "hooks": []any{map[string]any{ - "type": "command", "command": strings.Replace(command, droidGuardScript, droidGuardScript+".backup", 1), "timeout": 10, - }}, +func TestDroidStatusSurfacesMalformedRuntimeIdentity(t *testing.T) { + tests := []struct { + name string + mutate func(*droidFixture) + pattern string + }{ + { + "malformed config", + func(fixture *droidFixture) { + if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { + t.Fatalf("corrupt runtime config: %v", err) + } + }, + "parse Elydora runtime config", }, - map[string]any{ - "matcher": "*", - "hooks": []any{map[string]any{ - "type": "command", "command": strings.Replace(command, "agent-1", "agent-10", 1), "timeout": 10, - }}, + { + "unsupported config field", + func(fixture *droidFixture) { + config := readDroidTestObject(t, fixture.runtimeConfig) + config["future"] = true + writeDroidTestObject(t, fixture.runtimeConfig, config) + }, + "unsupported field", + }, + { + "noncanonical key", + func(fixture *droidFixture) { + if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil { + t.Fatalf("corrupt private key: %v", err) + } + }, + "canonical 32-byte", }, - ) - writeDroidTestObject(t, fixture.configPath, root) - if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil { - t.Fatalf("uninstall Factory Droid hooks: %v", err) } - remainingRaw := readDroidTestFile(t, fixture.configPath) - for _, marker := range []string{"user-command", droidGuardScript + ".backup", "agent-10"} { - if !strings.Contains(remainingRaw, marker) { - t.Fatalf("uninstall removed lookalike %q", marker) - } - } - remaining := readDroidTestObject(t, fixture.configPath) - if len(requireDroidArray(t, remaining["PostToolUse"])) != 0 { - t.Fatalf("PostToolUse after uninstall = %#v", remaining["PostToolUse"]) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + test.mutate(fixture) + _, err := fixture.plugin.Status() + if err == nil || !strings.Contains(err.Error(), test.pattern) { + t.Fatalf("status error = %v, want %q", err, test.pattern) + } + }) } } func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) { tests := []struct { name string - hooks *string + root *string legacy *string settings *string + local *string pattern string }{ - {"malformed hooks", droidString("{ malformed"), nil, nil, "parse Factory Droid hooks"}, - {"non-object hooks", droidJSON([]any{}), nil, nil, "JSON object"}, - {"duplicate event", droidString(`{ "PreToolUse": [], "PreToolUse": [] }`), nil, nil, "duplicate"}, - {"nested duplicate", droidString(`{ "PreToolUse": [{ "hooks": [], "hooks": [] }] }`), nil, nil, "duplicate"}, - {"null event", droidString(`{ "PreToolUse": null }`), nil, nil, "must be an array"}, - {"null group", droidString(`{ "PreToolUse": [null] }`), nil, nil, "must be an object"}, - {"invalid matcher", droidString(`{ "PreToolUse": [{ "matcher": "[", "hooks": [] }] }`), nil, nil, "regular expression"}, - {"invalid handler", droidString(`{ "PreToolUse": [{ "hooks": [{ "type": "command", "command": 1 }] }] }`), nil, nil, "must be a string"}, - {"invalid flag", droidString(`{ "hooksDisabled": "yes" }`), nil, nil, "must be a boolean"}, - {"unsupported root field", droidString(`{ "theme": "dark" }`), nil, nil, "unsupported field"}, - {"malformed legacy", nil, droidString("{ malformed"), nil, "legacy hooks"}, - {"malformed settings", droidString(`{ "PreToolUse": [] }`), nil, droidString("{ malformed"), "settings"}, - {"null settings hooks", nil, nil, droidString(`{ "hooks": null }`), "JSON object"}, + {"malformed root", droidString("{ malformed"), nil, nil, nil, "parse Factory Droid hooks"}, + {"non-object root", droidJSON([]any{}), nil, nil, nil, "JSON object"}, + {"duplicate event", droidString(`{"hooks":{"PreToolUse":[],"PreToolUse":[]}}`), nil, nil, nil, "duplicate"}, + {"null event", droidString(`{"hooks":{"PreToolUse":null}}`), nil, nil, nil, "must be an array"}, + {"null group", droidString(`{"hooks":{"PreToolUse":[null]}}`), nil, nil, nil, "must be an object"}, + { + "invalid matcher", + droidString(`{"hooks":{"PreToolUse":[{"matcher":"[","hooks":[]}]}}`), + nil, nil, nil, "regular expression", + }, + { + "empty handler command", + droidString(`{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":""}]}]}}`), + nil, nil, nil, "non-empty", + }, + { + "nonpositive timeout", + droidString(`{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"user","timeout":0}]}]}}`), + nil, nil, nil, "positive", + }, + {"malformed legacy", nil, droidString("{ malformed"), nil, nil, "legacy hooks"}, + {"null settings hooks", nil, nil, droidString(`{"hooks":null}`), nil, "JSON object"}, + {"invalid local flag", nil, nil, nil, droidString(`{"hooksDisabled":"yes"}`), "boolean"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { fixture := prepareDroidFixture(t, droidFixtureOptions{ - hooks: test.hooks, legacyHooks: test.legacy, settings: test.settings, + root: test.root, + legacy: test.legacy, + settings: test.settings, + localSettings: test.local, }) originals := existingDroidSources(t, fixture) err := fixture.plugin.Install(fixture.config) @@ -87,41 +107,57 @@ func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) { t.Fatalf("failed install changed %s", path) } } - for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + for _, path := range []string{ + fixture.guardPath, + fixture.hookPath, + fixture.runtimeConfig, + fixture.privateKey, + } { requireMissingDroidFile(t, path) } }) } } -func TestDroidInstallValidatesManagedPathsBeforeWrites(t *testing.T) { +func TestDroidUnknownEventsAndExtensionFieldsRemainValid(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{ + "hooks": map[string]any{ + "FutureFactoryEvent": []any{map[string]any{ + "matcher": ".*", + "commandRegex": "^factory", + "owner": "user", + "hooks": []any{map[string]any{ + "type": "command", + "command": "user-command", + "timeout": 1, + "future": true, + }}, + }}, + }, + })}) + installDroidFixture(t, fixture) + hooks := droidCurrentHooks(t, fixture.configPath) + future := requireDroidObject(t, requireDroidArray(t, hooks["FutureFactoryEvent"])[0]) + if future["owner"] != "user" { + t.Fatalf("future event = %#v", future) + } +} + +func TestDroidInstallValidatesConfigurationBeforeWrites(t *testing.T) { tests := []struct { name string mutate func(*droidFixture) pattern string }{ + {"missing organization", func(f *droidFixture) { f.config.OrgID = "" }, "organization ID is required"}, + {"blank key ID", func(f *droidFixture) { f.config.KID = " " }, "key ID is required"}, + {"invalid private key", func(f *droidFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"}, + {"invalid base URL", func(f *droidFixture) { f.config.BaseURL = "relative" }, "absolute HTTP or HTTPS"}, + {"invalid agent ID", func(f *droidFixture) { f.config.AgentID = "../agent" }, "single non-empty path segment"}, { - name: "missing guard", - mutate: func(fixture *droidFixture) { - if err := os.Remove(fixture.guardPath); err != nil { - t.Fatalf("remove guard: %v", err) - } - }, - pattern: "guard runtime is missing", - }, - { - name: "invalid agent id", - mutate: func(fixture *droidFixture) { - fixture.config.AgentID = "../agent" - }, - pattern: "single non-empty path segment", - }, - { - name: "guard outside managed directory", - mutate: func(fixture *droidFixture) { - fixture.config.GuardScriptPath = filepath.Join(fixture.homeDir, "guard.js") - }, - pattern: "managed agent directory", + "guard outside managed directory", + func(f *droidFixture) { f.config.GuardScriptPath = filepath.Join(f.homeDir, "guard.js") }, + "managed agent directory", }, } for _, test := range tests { @@ -132,111 +168,145 @@ func TestDroidInstallValidatesManagedPathsBeforeWrites(t *testing.T) { if err == nil || !strings.Contains(err.Error(), test.pattern) { t.Fatalf("install error = %v, want substring %q", err, test.pattern) } - for _, path := range []string{fixture.configPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { - requireMissingDroidFile(t, path) - } + requireMissingDroidFile(t, fixture.configPath) + requireMissingDroidFile(t, fixture.runtimeConfig) }) } } -func TestDroidTransactionRollsBackAllFilesAndCleansStaging(t *testing.T) { - fixture := prepareDroidFixture(t, droidFixtureOptions{ - hooks: droidJSON(map[string]any{"PreToolUse": []any{}}), - settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}), - }) - originalRoot := readDroidTestFile(t, fixture.configPath) - originalSettings := readDroidTestFile(t, fixture.settingsPath) - failed := false - fixture.plugin.rename = func(source, destination string) error { - if !failed && sameDroidPath(destination, fixture.settingsPath) { - failed = true - return errors.New("simulated settings commit failure") - } - return os.Rename(source, destination) - } - err := fixture.plugin.Install(fixture.config) - if err == nil || !strings.Contains(err.Error(), "Write Factory Droid installation") || !failed { - t.Fatalf("transaction error = %v, failed = %v", err, failed) - } - if readDroidTestFile(t, fixture.configPath) != originalRoot || - readDroidTestFile(t, fixture.settingsPath) != originalSettings { - t.Fatal("transaction rollback did not restore hook sources") - } - for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { - requireMissingDroidFile(t, path) - } - requireNoDroidStagingFiles(t, fixture.homeDir) -} - -func TestDroidTransactionDetectsExternalChangesAndPreservesThem(t *testing.T) { - fixture := prepareDroidFixture(t, droidFixtureOptions{ - hooks: droidJSON(map[string]any{"PreToolUse": []any{}}), - settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}), - }) - external := "{\n \"external\": true,\n \"hooks\": { \"PostToolUse\": [] }\n}\n" - mutated := false - fixture.plugin.rename = func(source, destination string) error { - if !mutated { - mutated = true - if err := os.WriteFile(fixture.settingsPath, []byte(external), 0600); err != nil { - return err - } - } - return os.Rename(source, destination) - } - err := fixture.plugin.Install(fixture.config) - if err == nil || !strings.Contains(err.Error(), "changed during installation") { - t.Fatalf("external-change error = %v", err) - } - if readDroidTestFile(t, fixture.settingsPath) != external { - t.Fatal("transaction rollback overwrote an external settings change") - } - for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { - requireMissingDroidFile(t, path) - } - requireNoDroidStagingFiles(t, fixture.homeDir) -} - -func TestDroidRecoveryCommandsDoNotRequireNodeResolution(t *testing.T) { +func TestDroidOrphanedRuntimeIsPreservedWithoutVerifiableIdentity(t *testing.T) { fixture := prepareDroidFixture(t, droidFixtureOptions{}) - installDroidFixture(t, fixture) - t.Setenv("PATH", "") - status, err := fixture.plugin.Status() - if err != nil || !status.Installed { - t.Fatalf("status without PATH = %#v, %v", status, err) + if err := os.MkdirAll(fixture.agentDir, 0700); err != nil { + t.Fatalf("create orphaned runtime: %v", err) } - if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil { - t.Fatalf("uninstall without PATH: %v", err) + if err := os.WriteFile(fixture.guardPath, []byte("orphaned guard\n"), 0700); err != nil { + t.Fatalf("write orphaned guard: %v", err) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "identity cannot be verified") { + t.Fatalf("orphaned runtime error = %v", err) + } + if readDroidTestFile(t, fixture.guardPath) != "orphaned guard\n" { + t.Fatal("orphaned guard changed") } requireMissingDroidFile(t, fixture.configPath) } -func TestDroidStatusSurfacesMalformedRuntimeConfig(t *testing.T) { - fixture := prepareDroidFixture(t, droidFixtureOptions{}) +func TestDroidUninstallPreservesUsersAndExactOwnership(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{ + "hooks": map[string]any{"Notification": []any{}}, + })}) installDroidFixture(t, fixture) - if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { - t.Fatalf("corrupt runtime config: %v", err) + root := readDroidTestObject(t, fixture.configPath) + hooks := requireDroidObject(t, root["hooks"]) + group := droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath) + command := droidManagedHandler(t, []any{group}, fixture.guardPath)["command"].(string) + group["hooks"] = append(requireDroidArray(t, group["hooks"]), map[string]any{ + "type": "command", "command": "user-command", + }) + group["owner"] = "user" + hooks["PreToolUse"] = append(requireDroidArray(t, hooks["PreToolUse"]), + map[string]any{ + "matcher": "*", + "hooks": []any{map[string]any{ + "type": "command", + "command": strings.Replace(command, droidGuardScript, droidGuardScript+".backup", 1), + "timeout": 10, + }}, + }, + map[string]any{ + "matcher": "*", + "hooks": []any{map[string]any{ + "type": "command", + "command": strings.Replace(command, droidTestAgentID, "agent-10", 1), + "timeout": 10, + }}, + }, + ) + writeDroidTestObject(t, fixture.configPath, root) + agentID := droidTestAgentID + if runtime.GOOS == "windows" { + agentID = strings.ToUpper(agentID) } - if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") { - t.Fatalf("status error = %v", err) + if err := fixture.plugin.Uninstall(agentID); err != nil { + t.Fatalf("uninstall Factory Droid hooks: %v", err) + } + remaining := readDroidTestFile(t, fixture.configPath) + for _, marker := range []string{"user-command", droidGuardScript + ".backup", "agent-10"} { + if !strings.Contains(remaining, marker) { + t.Fatalf("uninstall removed %q", marker) + } + } + currentHooks := droidCurrentHooks(t, fixture.configPath) + if _, exists := currentHooks["PostToolUse"]; exists { + t.Fatalf("managed PostToolUse remains: %#v", currentHooks) } } -func writeDroidTestObject(t *testing.T, path string, value map[string]any) { - t.Helper() - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - t.Fatalf("encode %s: %v", path, err) +func TestDroidUninstallRemovesOwnedEmptyFileAndKeepsAbsence(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil { + t.Fatalf("uninstall Factory Droid hooks: %v", err) } - if err := os.WriteFile(path, append(encoded, '\n'), 0600); err != nil { - t.Fatalf("write %s: %v", path, err) + requireMissingDroidFile(t, fixture.configPath) + + empty := prepareDroidFixture(t, droidFixtureOptions{}) + if err := empty.plugin.Uninstall(droidTestAgentID); err != nil { + t.Fatalf("uninstall absent hooks: %v", err) + } + requireMissingDroidFile(t, empty.configPath) +} + +func TestDroidInstallRejectsLinkedPathsBeforeWrites(t *testing.T) { + for _, kind := range []string{"factory", "hook", "runtime"} { + t.Run(kind, func(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + target := filepath.Join(t.TempDir(), kind+"-target") + if err := os.MkdirAll(target, 0755); err != nil { + t.Fatalf("create symlink target: %v", err) + } + var linkErr error + switch kind { + case "factory": + linkErr = os.MkdirAll(fixture.homeDir, 0755) + if linkErr == nil { + linkErr = os.Symlink(target, fixture.factoryDir) + } + case "hook": + if err := os.MkdirAll(fixture.factoryDir, 0755); err != nil { + t.Fatalf("create Factory directory: %v", err) + } + targetFile := filepath.Join(target, "hooks.json") + writeDroidTestObject(t, targetFile, map[string]any{"hooks": map[string]any{}}) + linkErr = os.Symlink(targetFile, fixture.configPath) + case "runtime": + linkErr = os.MkdirAll(fixture.homeDir, 0755) + if linkErr == nil { + linkErr = os.Symlink(target, filepath.Join(fixture.homeDir, ".elydora")) + } + } + if linkErr != nil { + t.Skipf("symbolic links unavailable: %v", linkErr) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "physical") { + t.Fatalf("linked-path error = %v", err) + } + requireMissingDroidFile(t, fixture.runtimeConfig) + }) } } func existingDroidSources(t *testing.T, fixture *droidFixture) map[string]string { t.Helper() result := map[string]string{} - for _, path := range []string{fixture.configPath, fixture.legacyPath, fixture.settingsPath} { + for _, path := range []string{ + fixture.configPath, + fixture.legacyPath, + fixture.settingsPath, + fixture.localSettingsPath, + } { if raw, err := os.ReadFile(path); err == nil { result[path] = string(raw) } else if !errors.Is(err, os.ErrNotExist) { @@ -245,19 +315,3 @@ func existingDroidSources(t *testing.T, fixture *droidFixture) map[string]string } return result } - -func requireNoDroidStagingFiles(t *testing.T, root string) { - t.Helper() - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback") { - t.Errorf("staging file remains: %s", path) - } - return nil - }) - if err != nil { - t.Fatalf("walk staging files: %v", err) - } -} diff --git a/cmd/elydora/plugins/droid_status.go b/cmd/elydora/plugins/droid_status.go new file mode 100644 index 0000000..2f9ed24 --- /dev/null +++ b/cmd/elydora/plugins/droid_status.go @@ -0,0 +1,251 @@ +package plugins + +import ( + "bytes" + "fmt" + "path/filepath" + "sort" + "strings" +) + +func readDroidRuntimeConfig(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 + } + config, err := decodeStrictJSONObject( + snapshot.contents, + fmt.Sprintf("Elydora runtime config at %s", path), + ) + return config, true, err +} + +func requireDroidRuntimeString( + config map[string]any, + field, 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 validateDroidRuntimeConfig( + config map[string]any, + expectedAgentID, 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 := requireDroidRuntimeString(config, "org_id", configPath); err != nil { + return err + } + if _, err := requireDroidRuntimeString(config, "kid", configPath); err != nil { + return err + } + agentID, err := requireDroidRuntimeString(config, "agent_id", configPath) + if err != nil { + return err + } + if !sameDroidAgentID(agentID, expectedAgentID) || config["agent_name"] != droidAgentKey { + return fmt.Errorf( + "Elydora runtime identity does not match Factory Droid hooks: %s", + configPath, + ) + } + if _, exists := config["token"]; exists { + if _, err := requireDroidRuntimeString(config, "token", configPath); err != nil { + return err + } + } + baseURL, err := requireDroidRuntimeString(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 validateDroidRuntimeIdentity(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 := readDroidRuntimeConfig(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, droidGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, droidAuditScript), "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"] != droidAgentKey || + !sameDroidAgentID(configuredID, agentID) { + return fmt.Errorf( + "Elydora runtime config identity does not match Factory Droid agent %s: %s", + agentID, + configPath, + ) + } + return nil +} + +func validDroidContractPaths(contract droidRuntimeContract) (bool, error) { + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return false, err + } + agentDirectory := filepath.Dir(contract.guardPath) + return sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) && + sameDroidPath( + contract.guardPath, + filepath.Join(agentDirectory, droidGuardScript), + ) && + sameDroidPath( + contract.auditPath, + filepath.Join(agentDirectory, droidAuditScript), + ), nil +} + +func droidRuntimeContractExists(contract droidRuntimeContract) (bool, error) { + validPaths, err := validDroidContractPaths(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 := readDroidRuntimeConfig(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 := validateDroidRuntimeConfig(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(droidAgentKey, contract.agentID, "", false, "")) + expectedAudit := []byte(buildHookScriptWithOutput( + droidAgentKey, + contract.agentID, + "", + false, + true, + )) + return bytes.Equal(guard.contents, expectedGuard) && + bytes.Equal(audit.contents, expectedAudit), nil +} + +func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) { + for _, contract := range contracts { + exists, err := droidRuntimeContractExists(contract) + if err != nil { + return false, err + } + if exists { + return true, nil + } + } + return false, nil +} diff --git a/cmd/elydora/plugins/droid_test.go b/cmd/elydora/plugins/droid_test.go index 523c065..0c7df37 100644 --- a/cmd/elydora/plugins/droid_test.go +++ b/cmd/elydora/plugins/droid_test.go @@ -2,12 +2,18 @@ package plugins import ( "encoding/json" + "fmt" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" + "reflect" "runtime" "strings" + "sync" "testing" + "time" ) func TestDroidRegistryUsesOfficialGlobalHookSource(t *testing.T) { @@ -15,121 +21,14 @@ func TestDroidRegistryUsesOfficialGlobalHookSource(t *testing.T) { if entry.Name != "Factory Droid" || entry.ConfigDir != "~/.factory" || entry.ConfigFile != "hooks.json" { t.Fatalf("Factory Droid registry entry = %#v", entry) } - if _, ok := NewPlugin(droidAgentKey).(*DroidPlugin); !ok { - t.Fatal("Factory Droid plugin is not registered") + plugin, ok := NewPlugin(droidAgentKey).(*DroidPlugin) + if !ok || !plugin.ManagesGuardRuntime() { + t.Fatal("Factory Droid plugin is not registered as a guard runtime manager") } } -func TestDroidInstallPreservesJSONCAndUsesPerEventPrecedence(t *testing.T) { - hooks := `{ - // root hook source - "PreToolUse": [ - // keep root group comment - { "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] } - ], - "Notification": [] -} -` - settings := `{ - // general setting - "theme": "dark", - "hooks": { - // settings fallback event - "PostToolUse": [ - // keep settings group comment - { "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] } - ], - "showHookOutput": true, - }, -} -` - fixture := prepareDroidFixture(t, droidFixtureOptions{ - hooks: droidString(hooks), settings: droidString(settings), - }) - installDroidFixture(t, fixture) - firstRoot := readDroidTestFile(t, fixture.configPath) - firstSettings := readDroidTestFile(t, fixture.settingsPath) - installDroidFixture(t, fixture) - - if readDroidTestFile(t, fixture.configPath) != firstRoot || - readDroidTestFile(t, fixture.settingsPath) != firstSettings { - t.Fatal("idempotent install changed Factory Droid hook sources") - } - for _, marker := range []string{"root hook source", "keep root group comment"} { - if !strings.Contains(firstRoot, marker) { - t.Fatalf("root JSONC comment %q was lost", marker) - } - } - for _, marker := range []string{"general setting", "settings fallback event", "keep settings group comment"} { - if !strings.Contains(firstSettings, marker) { - t.Fatalf("settings JSONC comment %q was lost", marker) - } - } - root := readDroidTestObject(t, fixture.configPath) - userSettings := readDroidTestObject(t, fixture.settingsPath) - preGroups := requireDroidArray(t, root["PreToolUse"]) - if len(preGroups) != 2 || requireDroidObject(t, requireDroidArray(t, - requireDroidObject(t, preGroups[0])["hooks"])[0])["command"] != "root-user" { - t.Fatalf("root PreToolUse groups = %#v", preGroups) - } - if _, exists := root["PostToolUse"]; exists { - t.Fatalf("PostToolUse leaked into root source: %#v", root) - } - if userSettings["theme"] != "dark" { - t.Fatalf("settings theme = %#v", userSettings["theme"]) - } - settingsHooks := requireDroidObject(t, userSettings["hooks"]) - postGroups := requireDroidArray(t, settingsHooks["PostToolUse"]) - if len(postGroups) != 2 { - t.Fatalf("settings PostToolUse groups = %#v", postGroups) - } - if _, exists := settingsHooks["PreToolUse"]; exists { - t.Fatalf("PreToolUse leaked into settings source: %#v", settingsHooks) - } - for _, item := range []struct { - groups any - path string - }{{preGroups, fixture.guardPath}, {postGroups, fixture.hookPath}} { - handler := droidManagedHandler(t, item.groups, item.path) - if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != float64(10) { - t.Fatalf("managed handler = %#v", handler) - } - } - requireMissingDroidFile(t, filepath.Join(fixture.workspaceDir, ".factory", "hooks.json")) -} - -func TestDroidInstallKeepsActiveLegacySource(t *testing.T) { - fixture := prepareDroidFixture(t, droidFixtureOptions{ - legacyHooks: droidJSON(map[string]any{"PreToolUse": []any{}}), - settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}), - }) - installDroidFixture(t, fixture) - requireMissingDroidFile(t, fixture.configPath) - droidManagedHandler(t, readDroidTestObject(t, fixture.legacyPath)["PreToolUse"], fixture.guardPath) - settings := readDroidTestObject(t, fixture.settingsPath) - droidManagedHandler(t, requireDroidObject(t, settings["hooks"])["PostToolUse"], fixture.hookPath) -} - -func TestDroidInstallReusesSettingsContainerAndFormatting(t *testing.T) { - source := "{\r\n\t\"owner\": \"user\",\r\n\t\"hooks\": {}\r\n}\r\n" - fixture := prepareDroidFixture(t, droidFixtureOptions{settings: droidString(source)}) - installDroidFixture(t, fixture) - requireMissingDroidFile(t, fixture.configPath) - raw := readDroidTestFile(t, fixture.settingsPath) - if !strings.Contains(raw, "\r\n\t\t\"PreToolUse\"") || - !strings.Contains(raw, "\r\n\t\t\"PostToolUse\"") { - t.Fatalf("settings formatting was not preserved:\n%s", raw) - } - if readDroidTestObject(t, fixture.settingsPath)["owner"] != "user" { - t.Fatal("settings owner was lost") - } -} - -func TestDroidInstallReportsEventSourcesAndReviewCommand(t *testing.T) { - fixture := prepareDroidFixture(t, droidFixtureOptions{ - hooks: droidJSON(map[string]any{"PreToolUse": []any{}}), - settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}), - }) +func TestDroidInstallWritesCurrentContainerAndCompleteRuntime(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) reader, writer, err := os.Pipe() if err != nil { t.Fatalf("create output pipe: %v", err) @@ -144,120 +43,326 @@ func TestDroidInstallReportsEventSourcesAndReviewCommand(t *testing.T) { if installErr != nil || closeErr != nil || readErr != nil { t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr) } - text := string(output) - for _, marker := range []string{fixture.configPath, fixture.settingsPath, "PreToolUse", "PostToolUse", "/hooks"} { - if !strings.Contains(text, marker) { - t.Fatalf("install output is missing %q: %s", marker, text) + if !strings.Contains(string(output), fixture.configPath) || !strings.Contains(string(output), "run /hooks") { + t.Fatalf("install output = %s", output) + } + source := readDroidTestFile(t, fixture.configPath) + if !strings.HasPrefix(source, droidOwnedFileMarker+"\n") { + t.Fatalf("managed source header is missing: %s", source) + } + hooks := droidCurrentHooks(t, fixture.configPath) + guardGroup := droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath) + auditGroup := droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath) + requireDroidNativeGroup(t, guardGroup) + requireDroidNativeGroup(t, auditGroup) + guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string) + if runtime.GOOS == "windows" && + (!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, droidWindowsExitSuffix)) { + t.Fatalf("Windows Factory command = %q", guardCommand) + } + runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig) + expectedConfig := map[string]any{ + "org_id": "org-1", + "agent_id": droidTestAgentID, + "kid": "kid-1", + "base_url": "http://127.0.0.1:9", + "agent_name": droidAgentKey, + "token": "token-1", + } + if !reflect.DeepEqual(runtimeConfig, expectedConfig) { + t.Fatalf("runtime config = %#v", runtimeConfig) + } + if readDroidTestFile(t, fixture.privateKey) != droidTestPrivateKey { + t.Fatal("private key content changed") + } + if !strings.Contains(readDroidTestFile(t, fixture.guardPath), `const AGENT_NAME = "droid"`) { + t.Fatal("guard runtime identity is missing") + } + if !strings.Contains(readDroidTestFile(t, fixture.hookPath), "const NATIVE_PAYLOAD = true") { + t.Fatal("audit runtime is missing native payload mode") + } + paths := []string{ + fixture.configPath, + fixture.guardPath, + fixture.hookPath, + fixture.runtimeConfig, + fixture.privateKey, + } + before := snapshotDroidFiles(t, paths...) + installDroidFixture(t, fixture) + requireDroidSnapshot(t, before) + requireNoDroidStagingFiles(t, fixture.homeDir) +} + +func TestDroidInstallMigratesLegacyWindowsCommands(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows migration contract") + } + fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{"hooks": map[string]any{}})}) + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js: %v", err) + } + group := func(script string) map[string]any { + return map[string]any{ + "matcher": "*", + "hooks": []any{map[string]any{ + "type": "command", + "command": fmt.Sprintf(`"%s" "%s"`, nodePath, script), + "timeout": 10, + }}, + } + } + writeDroidTestObject(t, fixture.configPath, map[string]any{"hooks": map[string]any{ + "PreToolUse": []any{group(fixture.guardPath)}, + "PostToolUse": []any{group(fixture.hookPath)}, + }}) + installDroidFixture(t, fixture) + hooks := droidCurrentHooks(t, fixture.configPath) + for _, event := range droidToolEvents { + groups := requireDroidArray(t, hooks[event]) + if len(groups) != 1 { + t.Fatalf("%s groups = %#v", event, groups) + } + command := requireDroidObject(t, requireDroidArray(t, requireDroidObject(t, groups[0])["hooks"])[0])["command"].(string) + if !strings.HasPrefix(command, "& '") { + t.Fatalf("migrated command = %q", command) } } } -func TestDroidCommandsBlockAndForwardOfficialInputByteForByte(t *testing.T) { - fixture := prepareDroidFixture(t, droidFixtureOptions{}) +func TestDroidRootHookFileHasWholeSourcePrecedence(t *testing.T) { + root := `{ + // active root source + "hooks": { + "PreToolUse": [ + { "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] } + ] + } +} +` + settings := `{ + // inactive settings source + "theme": "dark", + "hooks": { + "PostToolUse": [ + { "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] } + ] + } +} +` + fixture := prepareDroidFixture(t, droidFixtureOptions{ + root: droidString(root), + settings: droidString(settings), + }) installDroidFixture(t, fixture) - capturePath := filepath.Join(t.TempDir(), "captured-event.json") - encodedPath, err := json.Marshal(capturePath) - if err != nil { - t.Fatalf("marshal capture path: %v", err) + if !strings.Contains(readDroidTestFile(t, fixture.configPath), "active root source") { + t.Fatal("root JSONC comment was lost") } - captureScript := "const fs = require('node:fs'); const chunks = []; " + - "process.stdin.on('data', chunk => chunks.push(chunk)); " + - "process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n" - if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil { - t.Fatalf("write capture audit runtime: %v", err) + hooks := droidCurrentHooks(t, fixture.configPath) + pre := requireDroidArray(t, hooks["PreToolUse"]) + userCommand := requireDroidObject(t, requireDroidArray(t, requireDroidObject(t, pre[0])["hooks"])[0])["command"] + if userCommand != "root-user" { + t.Fatalf("root user command = %#v", userCommand) } - root := readDroidTestObject(t, fixture.configPath) - guard := droidManagedHandler(t, root["PreToolUse"], fixture.guardPath) - audit := droidManagedHandler(t, root["PostToolUse"], fixture.hookPath) - prePayload := `{"session_id":"session-1","cwd":"C:/workspace","permission_mode":"auto-high","hook_event_name":"PreToolUse","tool_name":"Execute","tool_input":{"command":"echo test"}}` - guardResult := runDroidCommand(t, guard["command"].(string), fixture.homeDir, prePayload) - if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") { - t.Fatalf("guard result = %#v", guardResult) - } - postPayload := "{\n \"session_id\": \"session-1\",\n \"hook_event_name\": \"PostToolUse\",\n \"tool_response\": {\"success\": true}\n}" - auditResult := runDroidCommand(t, audit["command"].(string), fixture.homeDir, postPayload) - if auditResult.exitCode != 0 { - t.Fatalf("audit result = %#v", auditResult) - } - if readDroidTestFile(t, capturePath) != postPayload { - t.Fatal("audit hook changed the official Factory Droid payload") + requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath)) + requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath)) + if readDroidTestFile(t, fixture.settingsPath) != settings { + t.Fatal("inactive settings source changed") } } -func TestDroidStatusRequiresEnabledPairAndRuntimeFiles(t *testing.T) { +func TestDroidSettingsSourcePreservesFormatting(t *testing.T) { + settings := "{\r\n\t\"theme\": \"dark\",\r\n\t\"hooks\": {}\r\n}\r\n" + fixture := prepareDroidFixture(t, droidFixtureOptions{settings: droidString(settings)}) + installDroidFixture(t, fixture) + requireMissingDroidFile(t, fixture.configPath) + raw := readDroidTestFile(t, fixture.settingsPath) + if !strings.Contains(raw, "\r\n\t\t\"PreToolUse\"") || + !strings.Contains(raw, "\r\n\t\t\"PostToolUse\"") { + t.Fatalf("settings formatting changed:\n%s", raw) + } + hooks := requireDroidObject(t, readDroidTestObject(t, fixture.settingsPath)["hooks"]) + requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath)) + requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath)) +} + +func TestDroidLocalSettingsContainerHasWholeSourcePrecedence(t *testing.T) { + settings := droidJSON(map[string]any{"hooks": map[string]any{"Notification": []any{}}}) fixture := prepareDroidFixture(t, droidFixtureOptions{ - hooks: droidJSON(map[string]any{"PreToolUse": []any{}}), - settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}), + settings: settings, + localSettings: droidJSON(map[string]any{"hooks": map[string]any{"SessionStart": []any{}}}), }) + baseBefore := readDroidTestFile(t, fixture.settingsPath) + installDroidFixture(t, fixture) + if readDroidTestFile(t, fixture.settingsPath) != baseBefore { + t.Fatal("base settings changed while local settings were active") + } + hooks := requireDroidObject(t, readDroidTestObject(t, fixture.localSettingsPath)["hooks"]) + requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath)) + requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath)) +} + +func TestDroidLegacyHookFileStaysActiveUntilFactoryMigratesIt(t *testing.T) { + legacy := droidJSON(map[string]any{"PreToolUse": []any{map[string]any{ + "matcher": "Read", + "hooks": []any{map[string]any{"type": "command", "command": "legacy-user"}}, + }}}) + settings := droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}) + fixture := prepareDroidFixture(t, droidFixtureOptions{legacy: legacy, settings: settings}) + settingsBefore := readDroidTestFile(t, fixture.settingsPath) + installDroidFixture(t, fixture) + requireMissingDroidFile(t, fixture.configPath) + legacyHooks := readDroidTestObject(t, fixture.legacyPath) + requireDroidNativeGroup(t, droidManagedGroup(t, legacyHooks["PreToolUse"], fixture.guardPath)) + requireDroidNativeGroup(t, droidManagedGroup(t, legacyHooks["PostToolUse"], fixture.hookPath)) + if readDroidTestFile(t, fixture.settingsPath) != settingsBefore { + t.Fatal("inactive settings source changed") + } +} + +func TestDroidInstallCleansExactManagedHooksFromInactiveSources(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + writeDroidTestObject(t, fixture.settingsPath, map[string]any{ + "hooks": droidCurrentHooks(t, fixture.configPath), + "owner": "user", + }) + installDroidFixture(t, fixture) + settings := readDroidTestObject(t, fixture.settingsPath) + if settings["owner"] != "user" { + t.Fatalf("inactive settings owner = %#v", settings["owner"]) + } + hooks := requireDroidObject(t, settings["hooks"]) + if _, exists := hooks["PreToolUse"]; exists { + t.Fatalf("inactive PreToolUse remains: %#v", hooks) + } + if _, exists := hooks["PostToolUse"]; exists { + t.Fatalf("inactive PostToolUse remains: %#v", hooks) + } +} + +type droidAPIRequest struct { + method string + path string + body []byte +} + +func TestDroidGuardBlocksAndAuditPreservesNativePayload(t *testing.T) { + requests := make([]droidAPIRequest, 0) + var requestLock sync.Mutex + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + body, err := io.ReadAll(request.Body) + if err != nil { + t.Errorf("read API request: %v", err) + } + requestLock.Lock() + requests = append(requests, droidAPIRequest{request.Method, request.URL.Path, body}) + requestLock.Unlock() + writer.Header().Set("Content-Type", "application/json") + if request.Method == http.MethodPost { + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte(`{"operation":{"accepted":true}}`)) + return + } + _, _ = writer.Write([]byte(`{"agent":{"status":"active"}}`)) + })) + defer server.Close() + fixture := prepareDroidFixture(t, droidFixtureOptions{baseURL: server.URL}) + installDroidFixture(t, fixture) + hooks := droidCurrentHooks(t, fixture.configPath) + guard := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath) + audit := droidManagedHandler(t, hooks["PostToolUse"], fixture.hookPath) + writeDroidTestObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{ + "status": "frozen", + "cached_at": time.Now().UnixMilli(), + }) + prePayload := map[string]any{ + "session_id": "session-1", + "transcript_path": filepath.Join(fixture.homeDir, "transcript.jsonl"), + "cwd": fixture.workspaceDir, + "permission_mode": "auto-high", + "hook_event_name": "PreToolUse", + "tool_name": "Execute", + "tool_input": map[string]any{"command": "echo test"}, + } + preRaw, _ := json.Marshal(prePayload) + guardResult := runDroidCommand(t, guard["command"].(string), fixture.homeDir, string(preRaw)) + if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, `Agent "droid" is frozen`) { + t.Fatalf("guard result = %#v", guardResult) + } + postPayload := make(map[string]any, len(prePayload)+1) + for key, value := range prePayload { + postPayload[key] = value + } + postPayload["hook_event_name"] = "PostToolUse" + postPayload["tool_response"] = map[string]any{"output": "test", "success": true} + postRaw, _ := json.Marshal(postPayload) + auditResult := runDroidCommand(t, audit["command"].(string), fixture.homeDir, string(postRaw)) + if auditResult.exitCode != 0 { + t.Fatalf("audit result = %#v", auditResult) + } + requestLock.Lock() + defer requestLock.Unlock() + var operation map[string]any + for _, request := range requests { + if request.method == http.MethodPost { + if err := json.Unmarshal(request.body, &operation); err != nil { + t.Fatalf("decode audit request: %v", err) + } + break + } + } + if operation == nil || !reflect.DeepEqual(operation["payload"], postPayload) { + t.Fatalf("native operation = %#v", operation) + } + if !reflect.DeepEqual(operation["subject"], map[string]any{"session_id": "session-1"}) || + !reflect.DeepEqual(operation["action"], map[string]any{"tool": "Execute"}) { + t.Fatalf("native operation identity = %#v", operation) + } +} + +func TestDroidStatusRequiresExactHooksAndRuntimeSources(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) installDroidFixture(t, fixture) status, err := fixture.plugin.Status() if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists { t.Fatalf("installed status = %#v, %v", status, err) } - if err := os.Remove(fixture.hookPath); err != nil { - t.Fatalf("remove audit runtime: %v", err) + if err := os.WriteFile(fixture.hookPath, []byte("tampered\n"), 0700); err != nil { + t.Fatalf("tamper audit runtime: %v", err) } status, err = fixture.plugin.Status() if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists { - t.Fatalf("missing-runtime status = %#v, %v", status, err) - } - root := readDroidTestObject(t, fixture.configPath) - root["hooksDisabled"] = true - disabled, err := json.MarshalIndent(root, "", " ") - if err != nil { - t.Fatalf("encode disabled hooks: %v", err) - } - if err := os.WriteFile(fixture.configPath, append(disabled, '\n'), 0600); err != nil { - t.Fatalf("disable hooks: %v", err) - } - status, err = fixture.plugin.Status() - if err != nil || status.Installed || status.HookConfigured { - t.Fatalf("disabled status = %#v, %v", status, err) + t.Fatalf("tampered status = %#v, %v", status, err) } } -func TestDroidUninstallPreservesUserSourcesAndExactOwnership(t *testing.T) { - hooks := "{\n // keep root comment\n \"PreToolUse\": []\n}\n" - settings := "{\n \"theme\": \"dark\",\n \"hooks\": {\n" + - " // keep settings comment\n \"PostToolUse\": []\n }\n}\n" +func TestDroidStatusUsesOneWholeActiveSource(t *testing.T) { fixture := prepareDroidFixture(t, droidFixtureOptions{ - hooks: droidString(hooks), settings: droidString(settings), + settings: droidJSON(map[string]any{"hooks": map[string]any{}}), }) installDroidFixture(t, fixture) - if err := fixture.plugin.Uninstall("agent-10"); err != nil { - t.Fatalf("uninstall lookalike agent: %v", err) - } - droidManagedHandler(t, readDroidTestObject(t, fixture.configPath)["PreToolUse"], fixture.guardPath) - agentID := droidTestAgentID - if runtime.GOOS == "windows" { - agentID = "AGENT-1" - } - if err := fixture.plugin.Uninstall(agentID); err != nil { - t.Fatalf("uninstall Factory Droid hooks: %v", err) - } - rootRaw := readDroidTestFile(t, fixture.configPath) - settingsRaw := readDroidTestFile(t, fixture.settingsPath) - if !strings.Contains(rootRaw, "keep root comment") || !strings.Contains(settingsRaw, "keep settings comment") { - t.Fatal("uninstall removed user JSONC comments") - } - if len(requireDroidArray(t, readDroidTestObject(t, fixture.configPath)["PreToolUse"])) != 0 { - t.Fatal("uninstall removed the user-owned empty event key") - } - currentSettings := readDroidTestObject(t, fixture.settingsPath) - if currentSettings["theme"] != "dark" || - len(requireDroidArray(t, requireDroidObject(t, currentSettings["hooks"])["PostToolUse"])) != 0 { - t.Fatalf("settings after uninstall = %#v", currentSettings) + settingsHooks := requireDroidObject(t, readDroidTestObject(t, fixture.settingsPath)["hooks"]) + writeDroidTestObject(t, fixture.configPath, map[string]any{"hooks": map[string]any{ + "PreToolUse": settingsHooks["PreToolUse"], + }}) + status, err := fixture.plugin.Status() + if err != nil || status.HookConfigured || status.Installed { + t.Fatalf("split-source status = %#v, %v", status, err) } } -func TestDroidUninstallDeletesOnlyOwnedEmptyHookFile(t *testing.T) { +func TestDroidStatusAndUninstallDoNotRequireNodeResolution(t *testing.T) { fixture := prepareDroidFixture(t, droidFixtureOptions{}) installDroidFixture(t, fixture) - if !strings.HasPrefix(readDroidTestFile(t, fixture.configPath), droidOwnedFileMarker) { - t.Fatal("created hook source is missing its ownership marker") + t.Setenv("PATH", "") + status, err := fixture.plugin.Status() + if err != nil || !status.Installed { + t.Fatalf("status without PATH = %#v, %v", status, err) } if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil { - t.Fatalf("uninstall Factory Droid hooks: %v", err) + t.Fatalf("uninstall without PATH: %v", err) } requireMissingDroidFile(t, fixture.configPath) } diff --git a/cmd/elydora/plugins/droid_test_helpers_test.go b/cmd/elydora/plugins/droid_test_helpers_test.go index eb3307d..2b78a04 100644 --- a/cmd/elydora/plugins/droid_test_helpers_test.go +++ b/cmd/elydora/plugins/droid_test_helpers_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "io/fs" "os" "os/exec" "path/filepath" @@ -12,33 +13,45 @@ import ( "testing" ) -const droidTestAgentID = "agent-1" +const ( + droidTestAgentID = "agent-1" + droidTestPrivateKey = "DQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0" +) type droidFixtureOptions struct { - hooks *string - legacyHooks *string - settings *string - skipGuard bool + root *string + legacy *string + settings *string + localSettings *string + projectSettings *string + projectLocalSettings *string + baseURL string + agentID string } type droidFixture struct { - plugin *DroidPlugin - config InstallConfig - homeDir string - workspaceDir string - factoryDir string - configPath string - legacyPath string - settingsPath string - agentDir string - guardPath string - hookPath string - runtimeConfig string - privateKey string + plugin *DroidPlugin + config InstallConfig + homeDir string + workspaceDir string + factoryDir string + configPath string + legacyPath string + settingsPath string + localSettingsPath string + projectSettingsPath string + projectLocalSettingsPath string + systemSettingsPath string + agentDir string + guardPath string + hookPath string + runtimeConfig string + privateKey string } type droidCommandResult struct { exitCode int + stdout string stderr string } @@ -57,51 +70,78 @@ func droidJSON(value any) *string { func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixture { t.Helper() rootDir := t.TempDir() - homeDir := filepath.Join(rootDir, "home with spaces and 'quote") - workspaceDir := filepath.Join(rootDir, "workspace") + homeDir := filepath.Join(rootDir, "home with spaces and 'quote %DROID%") + workspaceDir := filepath.Join(rootDir, "workspace with spaces") factoryDir := filepath.Join(homeDir, ".factory") configPath := filepath.Join(factoryDir, "hooks.json") legacyPath := filepath.Join(factoryDir, "hooks", "hooks.json") settingsPath := filepath.Join(factoryDir, "settings.json") - agentDir := filepath.Join(homeDir, ".elydora", droidTestAgentID) + localSettingsPath := filepath.Join(factoryDir, "settings.local.json") + projectSettingsPath := filepath.Join(workspaceDir, ".factory", "settings.json") + projectLocalSettingsPath := filepath.Join(workspaceDir, ".factory", "settings.local.json") + systemSettingsPath := filepath.Join(rootDir, "managed factory", "settings.json") + agentID := options.agentID + if agentID == "" { + agentID = droidTestAgentID + } + agentDir := filepath.Join(homeDir, ".elydora", agentID) guardPath := filepath.Join(agentDir, droidGuardScript) hookPath := filepath.Join(agentDir, droidAuditScript) - for _, directory := range []string{workspaceDir, agentDir} { - if err := os.MkdirAll(directory, 0755); err != nil { - t.Fatalf("create fixture directory %s: %v", directory, err) - } + if err := os.MkdirAll(filepath.Join(workspaceDir, ".git"), 0755); err != nil { + t.Fatalf("create fixture workspace: %v", err) } - if !options.skipGuard { - guard := "process.stdin.resume(); 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) - } - } - writeOptionalDroidFile(t, configPath, options.hooks) - writeOptionalDroidFile(t, legacyPath, options.legacyHooks) + writeOptionalDroidFile(t, configPath, options.root) + writeOptionalDroidFile(t, legacyPath, options.legacy) writeOptionalDroidFile(t, settingsPath, options.settings) + writeOptionalDroidFile(t, localSettingsPath, options.localSettings) + writeOptionalDroidFile(t, projectSettingsPath, options.projectSettings) + writeOptionalDroidFile(t, projectLocalSettingsPath, options.projectLocalSettings) t.Setenv("HOME", homeDir) t.Setenv("USERPROFILE", homeDir) + oldDirectory, err := os.Getwd() + if err != nil { + t.Fatalf("read working directory: %v", err) + } + if err := os.Chdir(workspaceDir); err != nil { + t.Fatalf("enter fixture workspace: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(oldDirectory); err != nil { + t.Errorf("restore working directory: %v", err) + } + }) + previousManagedPath := droidManagedSettingsPath + droidManagedSettingsPath = func() string { return systemSettingsPath } + t.Cleanup(func() { droidManagedSettingsPath = previousManagedPath }) + baseURL := options.baseURL + if baseURL == "" { + baseURL = "http://127.0.0.1:9" + } return &droidFixture{ - plugin: &DroidPlugin{}, - homeDir: homeDir, - workspaceDir: workspaceDir, - factoryDir: factoryDir, - configPath: configPath, - legacyPath: legacyPath, - settingsPath: settingsPath, - agentDir: agentDir, - guardPath: guardPath, - hookPath: hookPath, - runtimeConfig: filepath.Join(agentDir, "config.json"), - privateKey: filepath.Join(agentDir, "private.key"), + plugin: &DroidPlugin{}, + homeDir: homeDir, + workspaceDir: workspaceDir, + factoryDir: factoryDir, + configPath: configPath, + legacyPath: legacyPath, + settingsPath: settingsPath, + localSettingsPath: localSettingsPath, + projectSettingsPath: projectSettingsPath, + projectLocalSettingsPath: projectLocalSettingsPath, + systemSettingsPath: systemSettingsPath, + agentDir: agentDir, + guardPath: guardPath, + hookPath: hookPath, + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), config: InstallConfig{ AgentName: droidAgentKey, OrgID: "org-1", - AgentID: droidTestAgentID, - PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", + AgentID: agentID, + PrivateKey: droidTestPrivateKey, KID: "kid-1", - BaseURL: "https://api.elydora.test", + Token: "token-1", + BaseURL: baseURL, GuardScriptPath: guardPath, }, } @@ -120,6 +160,11 @@ func writeOptionalDroidFile(t *testing.T, path string, source *string) { } } +func writeDroidTestObject(t *testing.T, path string, value any) { + t.Helper() + writeOptionalDroidFile(t, path, droidJSON(value)) +} + func installDroidFixture(t *testing.T, fixture *droidFixture) { t.Helper() if err := fixture.plugin.Install(fixture.config); err != nil { @@ -138,7 +183,7 @@ func readDroidTestFile(t *testing.T, path string) string { func requireMissingDroidFile(t *testing.T, path string) { t.Helper() - if _, err := os.Stat(path); !os.IsNotExist(err) { + if _, err := os.Lstat(path); !os.IsNotExist(err) { t.Fatalf("expected %s to be missing, got %v", path, err) } } @@ -146,7 +191,9 @@ func requireMissingDroidFile(t *testing.T, path string) { func readDroidTestObject(t *testing.T, path string) map[string]any { t.Helper() standard, err := standardizeJSONC( - []byte(readDroidTestFile(t, path)), "Factory Droid test source", true, + []byte(readDroidTestFile(t, path)), + "Factory Droid test source", + true, ) if err != nil { t.Fatalf("standardize %s: %v", path, err) @@ -158,6 +205,11 @@ func readDroidTestObject(t *testing.T, path string) map[string]any { return object } +func droidCurrentHooks(t *testing.T, path string) map[string]any { + t.Helper() + return requireDroidObject(t, readDroidTestObject(t, path)["hooks"]) +} + func requireDroidObject(t *testing.T, value any) map[string]any { t.Helper() object, ok := value.(map[string]any) @@ -176,24 +228,6 @@ func requireDroidArray(t *testing.T, value any) []any { return array } -func droidManagedHandler( - t *testing.T, - groupsValue any, - scriptPath string, -) map[string]any { - t.Helper() - group := droidManagedGroup(t, groupsValue, scriptPath) - for _, handlerValue := range requireDroidArray(t, group["hooks"]) { - handler := requireDroidObject(t, handlerValue) - command, _ := handler["command"].(string) - if strings.Contains(command, scriptPath) { - return handler - } - } - t.Fatalf("managed handler for %s not found", scriptPath) - return nil -} - func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[string]any { t.Helper() for _, groupValue := range requireDroidArray(t, groupsValue) { @@ -201,7 +235,8 @@ func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[str for _, handlerValue := range requireDroidArray(t, group["hooks"]) { handler := requireDroidObject(t, handlerValue) command, _ := handler["command"].(string) - if strings.Contains(command, scriptPath) { + _, configuredPath, managed := parseDroidCommand(command, true) + if managed && sameDroidPath(configuredPath, scriptPath) { return group } } @@ -210,30 +245,101 @@ func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[str return nil } +func droidManagedHandler(t *testing.T, groupsValue any, scriptPath string) map[string]any { + t.Helper() + group := droidManagedGroup(t, groupsValue, scriptPath) + for _, handlerValue := range requireDroidArray(t, group["hooks"]) { + handler := requireDroidObject(t, handlerValue) + command, _ := handler["command"].(string) + _, configuredPath, managed := parseDroidCommand(command, true) + if managed && sameDroidPath(configuredPath, scriptPath) { + return handler + } + } + t.Fatalf("managed handler for %s not found", scriptPath) + return nil +} + +func requireDroidNativeGroup(t *testing.T, group map[string]any) { + t.Helper() + if len(group) != 2 || group["matcher"] != "*" { + t.Fatalf("managed group = %#v", group) + } + handlers := requireDroidArray(t, group["hooks"]) + if len(handlers) != 1 { + t.Fatalf("managed handlers = %#v", handlers) + } + handler := requireDroidObject(t, handlers[0]) + if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != float64(10) { + t.Fatalf("managed handler = %#v", handler) + } +} + func runDroidCommand(t *testing.T, command, homeDir, payload string) droidCommandResult { t.Helper() var process *exec.Cmd if runtime.GOOS == "windows" { - commandFile := filepath.Join(t.TempDir(), "run-droid-hook.cmd") - if err := os.WriteFile(commandFile, []byte("@echo off\r\n"+command+"\r\n"), 0600); err != nil { - t.Fatalf("write command file: %v", err) - } - process = exec.Command("cmd.exe", "/d", "/c", commandFile) + process = exec.Command( + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + command, + ) } else { process = exec.Command("sh", "-c", command) } process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir) process.Stdin = bytes.NewBufferString(payload) + var stdout bytes.Buffer var stderr bytes.Buffer + process.Stdout = &stdout process.Stderr = &stderr err := process.Run() + result := droidCommandResult{stdout: stdout.String(), stderr: stderr.String()} if err == nil { - return droidCommandResult{stderr: stderr.String()} + return result } var exitError *exec.ExitError if errors.As(err, &exitError) { - return droidCommandResult{exitCode: exitError.ExitCode(), stderr: stderr.String()} + result.exitCode = exitError.ExitCode() + return result } t.Fatalf("run Factory Droid hook command: %v", err) return droidCommandResult{} } + +func snapshotDroidFiles(t *testing.T, paths ...string) map[string]string { + t.Helper() + result := make(map[string]string, len(paths)) + for _, path := range paths { + result[path] = readDroidTestFile(t, path) + } + return result +} + +func requireDroidSnapshot(t *testing.T, expected map[string]string) { + t.Helper() + for path, source := range expected { + if current := readDroidTestFile(t, path); current != source { + t.Fatalf("%s changed", path) + } + } +} + +func requireNoDroidStagingFiles(t *testing.T, root string) { + t.Helper() + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback") { + t.Errorf("staging file remains: %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("walk staging files: %v", err) + } +} diff --git a/cmd/elydora/plugins/droid_transaction_test.go b/cmd/elydora/plugins/droid_transaction_test.go new file mode 100644 index 0000000..136ed4d --- /dev/null +++ b/cmd/elydora/plugins/droid_transaction_test.go @@ -0,0 +1,270 @@ +package plugins + +import ( + "errors" + "os" + "strings" + "testing" +) + +func prepareDroidTestInstallation( + t *testing.T, + fixture *droidFixture, + config InstallConfig, +) *preparedDroidInstallation { + t.Helper() + sources, err := readDroidSources() + if err != nil { + t.Fatalf("read Factory Droid sources: %v", err) + } + paths, nodePath, err := preflightDroidInstallation(config, sources) + if err != nil { + t.Fatalf("preflight Factory Droid installation: %v", err) + } + rendered, err := renderDroidInstallation( + sources, + paths.guardPath, + paths.auditPath, + nodePath, + paths.runtimeRoot, + ) + if err != nil { + t.Fatalf("render Factory Droid installation: %v", err) + } + prepared, err := prepareDroidInstallation(config, sources, rendered) + if err != nil { + t.Fatalf("prepare Factory Droid installation: %v", err) + } + return prepared +} + +func droidManagedPaths(fixture *droidFixture) []string { + return []string{ + fixture.guardPath, + fixture.runtimeConfig, + fixture.privateKey, + fixture.hookPath, + fixture.configPath, + } +} + +func TestDroidInstallRollsBackRuntimeAfterLateHookCommitFailure(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + prepared := prepareDroidTestInstallation(t, fixture, fixture.config) + failed := false + rename := func(source, destination string) error { + if !failed && sameDroidPath(destination, fixture.configPath) { + failed = true + return errors.New("injected Droid hook commit failure") + } + return os.Rename(source, destination) + } + err := commitDroidInstallation(prepared, rename) + if err == nil || !strings.Contains(err.Error(), "injected Droid hook commit failure") || !failed { + t.Fatalf("transaction error = %v, failed = %v", err, failed) + } + for _, path := range droidManagedPaths(fixture) { + requireMissingDroidFile(t, path) + } + requireNoDroidStagingFiles(t, fixture.homeDir) +} + +func TestDroidConcurrentActiveHookReplacementPreventsRuntimeCommit(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + before := snapshotDroidFiles(t, droidManagedPaths(fixture)...) + updated := fixture.config + updated.OrgID = "org-updated" + prepared := prepareDroidTestInstallation(t, fixture, updated) + concurrent := "{\"hooks\":{\"PreToolUse\":[]},\"owner\":\"concurrent\"}\n" + if err := os.WriteFile(fixture.configPath, []byte(concurrent), 0600); err != nil { + t.Fatalf("replace active hook source: %v", err) + } + err := commitDroidInstallation(prepared, nil) + if err == nil || !strings.Contains(err.Error(), "changed during Install Factory Droid") { + t.Fatalf("concurrent source error = %v", err) + } + for path, source := range before { + if sameDroidPath(path, fixture.configPath) { + continue + } + if readDroidTestFile(t, path) != source { + t.Fatalf("runtime changed after concurrent source replacement: %s", path) + } + } + if readDroidTestFile(t, fixture.configPath) != concurrent { + t.Fatal("concurrent source was overwritten") + } + requireNoDroidStagingFiles(t, fixture.homeDir) +} + +func TestDroidConcurrentInactiveLocalSourceCreationIsDetected(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + before := snapshotDroidFiles(t, droidManagedPaths(fixture)...) + updated := fixture.config + updated.OrgID = "org-updated" + prepared := prepareDroidTestInstallation(t, fixture, updated) + concurrent := "{\"hooksDisabled\":true}\n" + writeOptionalDroidFile(t, fixture.localSettingsPath, droidString(concurrent)) + err := commitDroidInstallation(prepared, nil) + if err == nil || !strings.Contains(err.Error(), "local settings changed during Install") { + t.Fatalf("concurrent local source error = %v", err) + } + requireDroidSnapshot(t, before) + if readDroidTestFile(t, fixture.localSettingsPath) != concurrent { + t.Fatal("concurrent local settings source was overwritten") + } + requireNoDroidStagingFiles(t, fixture.homeDir) +} + +func TestDroidConcurrentProjectPolicyChangeIsDetected(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + projectSettings: droidJSON(map[string]any{"hooksDisabled": false}), + }) + installDroidFixture(t, fixture) + before := snapshotDroidFiles(t, droidManagedPaths(fixture)...) + updated := fixture.config + updated.OrgID = "org-updated" + prepared := prepareDroidTestInstallation(t, fixture, updated) + concurrent := "{\"hooksDisabled\":true}\n" + if err := os.WriteFile(fixture.projectSettingsPath, []byte(concurrent), 0600); err != nil { + t.Fatalf("change project policy: %v", err) + } + err := commitDroidInstallation(prepared, nil) + if err == nil || !strings.Contains(err.Error(), "project settings changed during Install") { + t.Fatalf("concurrent project policy error = %v", err) + } + requireDroidSnapshot(t, before) + if readDroidTestFile(t, fixture.projectSettingsPath) != concurrent { + t.Fatal("concurrent project policy was overwritten") + } + requireNoDroidStagingFiles(t, fixture.homeDir) +} + +func TestDroidConcurrentPolicyChangePreventsRuntimeDirectoryCreation(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + projectSettings: droidJSON(map[string]any{"hooksDisabled": false}), + }) + prepared := prepareDroidTestInstallation(t, fixture, fixture.config) + if err := os.WriteFile( + fixture.projectSettingsPath, + []byte("{\"hooksDisabled\":true}\n"), + 0600, + ); err != nil { + t.Fatalf("change project policy: %v", err) + } + err := commitDroidInstallation(prepared, nil) + if err == nil || !strings.Contains(err.Error(), "project settings changed during Install") { + t.Fatalf("concurrent project policy error = %v", err) + } + requireMissingDroidFile(t, fixture.agentDir) + requireMissingDroidFile(t, fixture.configPath) +} + +func TestDroidSameContentReplacementIsRejectedByPhysicalIdentity(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + before := snapshotDroidFiles(t, droidManagedPaths(fixture)...) + sources, err := readDroidSources() + if err != nil { + t.Fatalf("read Factory Droid sources: %v", err) + } + original := readDroidTestFile(t, fixture.configPath) + if err := os.Remove(fixture.configPath); err != nil { + t.Fatalf("remove hook source: %v", err) + } + if err := os.WriteFile(fixture.configPath, []byte(original), 0600); err != nil { + t.Fatalf("replace hook source: %v", err) + } + updated := fixture.config + updated.OrgID = "org-updated" + paths, nodePath, err := preflightDroidInstallation(updated, sources) + if err != nil { + t.Fatalf("preflight stale sources: %v", err) + } + rendered, err := renderDroidInstallation( + sources, + paths.guardPath, + paths.auditPath, + nodePath, + paths.runtimeRoot, + ) + if err != nil { + t.Fatalf("render stale sources: %v", err) + } + prepared, err := prepareDroidInstallation(updated, sources, rendered) + if err != nil { + t.Fatalf("prepare stale sources: %v", err) + } + err = commitDroidInstallation(prepared, nil) + if err == nil || !strings.Contains(err.Error(), "changed during Install") { + t.Fatalf("same-content replacement error = %v", err) + } + if readDroidTestFile(t, fixture.configPath) != original { + t.Fatal("same-content replacement changed") + } + for path, source := range before { + if !sameDroidPath(path, fixture.configPath) && readDroidTestFile(t, path) != source { + t.Fatalf("runtime changed after inode replacement: %s", path) + } + } + requireNoDroidStagingFiles(t, fixture.homeDir) +} + +func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + installed := droidCurrentHooks(t, fixture.configPath) + writeDroidTestObject(t, fixture.settingsPath, map[string]any{ + "hooks": installed, + "owner": "user", + }) + paths := append(droidManagedPaths(fixture), fixture.settingsPath) + before := snapshotDroidFiles(t, paths...) + sources, err := readDroidSources() + if err != nil { + t.Fatalf("read uninstall sources: %v", err) + } + runtimeRoot, err := droidRuntimeRoot() + if err != nil { + t.Fatalf("resolve runtime root: %v", err) + } + rendered, err := renderDroidUninstall(sources, droidTestAgentID, runtimeRoot) + if err != nil { + t.Fatalf("render uninstall: %v", err) + } + prepared, err := prepareDroidUninstall(rendered) + if err != nil { + t.Fatalf("prepare uninstall: %v", err) + } + failed := false + rename := func(source, destination string) error { + if !failed && sameDroidPath(destination, fixture.settingsPath) { + failed = true + return errors.New("injected Droid uninstall failure") + } + return os.Rename(source, destination) + } + err = commitDroidUninstall(prepared, rename) + if err == nil || !strings.Contains(err.Error(), "injected Droid uninstall failure") || !failed { + t.Fatalf("uninstall rollback error = %v, failed = %v", err, failed) + } + requireDroidSnapshot(t, before) + requireNoDroidStagingFiles(t, fixture.homeDir) +} + +func TestDroidPreflightBlocksDisabledHooksBeforeRuntimeCreation(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + settings: droidJSON(map[string]any{"hooksDisabled": true}), + }) + preflighter, ok := any(fixture.plugin).(InstallPreflighter) + if !ok { + t.Fatal("Factory Droid plugin does not implement InstallPreflighter") + } + err := preflighter.PreflightInstall(fixture.config) + if err == nil || !strings.Contains(err.Error(), "hooksDisabled") { + t.Fatalf("preflight error = %v", err) + } + requireMissingDroidFile(t, fixture.agentDir) +}