fix(droid): align hooks with Factory 0.175
This commit is contained in:
1 parent
827374e7f8
commit
4521cbe32f
15 files changed
+2749
-881
No files matched your search
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
-151
@@ -1,145 +1,143 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// DroidPlugin manages Factory Droid native user hooks.
|
||||||
type DroidPlugin struct {
|
type DroidPlugin struct {
|
||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
type droidRuntimeConfig struct {
|
// ManagesGuardRuntime reports that Droid commits its guard, audit runtime,
|
||||||
OrgID string `json:"org_id"`
|
// credentials, and hook documents in one transaction.
|
||||||
AgentID string `json:"agent_id"`
|
func (p *DroidPlugin) ManagesGuardRuntime() bool {
|
||||||
KID string `json:"kid"`
|
return true
|
||||||
BaseURL string `json:"base_url"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
AgentName string `json:"agent_name"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DroidPlugin) Install(config InstallConfig) error {
|
// PreflightInstall validates every hook source, policy layer, credential,
|
||||||
runtimeRoot, agentDirectory, err := droidAgentDirectory(config.AgentID)
|
// matcher, and runtime identity before any write.
|
||||||
if err != nil {
|
func (p *DroidPlugin) PreflightInstall(config InstallConfig) error {
|
||||||
return err
|
|
||||||
}
|
|
||||||
sources, err := readDroidSources()
|
sources, err := readDroidSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
settings := []droidHookSettings{sources.settings.hooks}
|
_, _, err = preflightDroidInstallation(config, sources)
|
||||||
if sources.primary != nil {
|
return err
|
||||||
settings = append(settings, sources.primary.hooks)
|
}
|
||||||
}
|
|
||||||
if err := validateDroidRegexes(settings...); err != nil {
|
func renderDroidInstallation(
|
||||||
return err
|
sources *droidSources,
|
||||||
}
|
guardPath, auditPath, nodePath, runtimeRoot string,
|
||||||
expectedGuard := filepath.Join(agentDirectory, droidGuardScript)
|
) ([]*droidRenderedDocument, error) {
|
||||||
if !sameDroidPath(config.GuardScriptPath, expectedGuard) {
|
target := activeDroidDocument(sources)
|
||||||
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
|
|
||||||
}
|
|
||||||
groups := map[string]map[string]any{
|
groups := map[string]map[string]any{
|
||||||
"PreToolUse": buildDroidGroup(nodePath, config.GuardScriptPath),
|
"PreToolUse": buildDroidGroup(nodePath, guardPath),
|
||||||
"PostToolUse": buildDroidGroup(nodePath, auditPath),
|
"PostToolUse": buildDroidGroup(nodePath, auditPath),
|
||||||
}
|
}
|
||||||
documents := uniqueDroidDocuments(
|
documents := droidInstallationDocuments(sources)
|
||||||
sources.primary,
|
|
||||||
droidSettingsDocument(sources.settings),
|
|
||||||
selection.targets["PreToolUse"],
|
|
||||||
selection.targets["PostToolUse"],
|
|
||||||
)
|
|
||||||
rendered := make([]*droidRenderedDocument, 0, len(documents))
|
rendered := make([]*droidRenderedDocument, 0, len(documents))
|
||||||
for _, document := range documents {
|
for _, document := range documents {
|
||||||
result, renderErr := renderDroidDocument(
|
item, err := renderDroidDocument(
|
||||||
document, "", runtimeRoot, droidAdditionsFor(document, selection.targets, groups),
|
document,
|
||||||
|
"",
|
||||||
|
runtimeRoot,
|
||||||
|
droidAdditionsFor(document, target, groups),
|
||||||
)
|
)
|
||||||
if renderErr != nil {
|
if err != nil {
|
||||||
return renderErr
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf(
|
rendered, err := renderDroidInstallation(
|
||||||
" Factory Droid: PreToolUse: %s, PostToolUse: %s\n",
|
sources,
|
||||||
selection.targets["PreToolUse"].filePath,
|
paths.guardPath,
|
||||||
selection.targets["PostToolUse"].filePath,
|
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
|
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 {
|
func (p *DroidPlugin) Uninstall(agentID string) error {
|
||||||
|
sources, err := readDroidSources()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
runtimeRoot, err := droidRuntimeRoot()
|
runtimeRoot, err := droidRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
sources, err := readDroidSources()
|
rendered, err := renderDroidUninstall(sources, agentID, runtimeRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
documents := uniqueDroidDocuments(sources.primary, droidSettingsDocument(sources.settings))
|
prepared, err := prepareDroidUninstall(rendered)
|
||||||
changes := make([]*fileChange, 0, len(documents))
|
if err != nil {
|
||||||
for _, document := range documents {
|
return err
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return writeChanges(changes, "Write Factory Droid hook sources", p.rename)
|
return commitDroidUninstall(prepared, p.rename)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DroidPlugin) Status() (PluginStatus, error) {
|
func (p *DroidPlugin) Status() (PluginStatus, error) {
|
||||||
entry := SupportedAgents[droidAgentKey]
|
entry := SupportedAgents[droidAgentKey]
|
||||||
status := PluginStatus{
|
status := PluginStatus{AgentName: droidAgentKey, DisplayName: entry.Name}
|
||||||
AgentName: droidAgentKey, DisplayName: entry.Name,
|
|
||||||
}
|
|
||||||
sources, err := readDroidSources()
|
sources, err := readDroidSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
status.ConfigPath = displayDroidConfigPath(sources)
|
status.ConfigPath = displayDroidConfigPath(sources)
|
||||||
primary := droidHookSettings{}
|
if droidHookBlocked(sources) != nil {
|
||||||
if sources.primary != nil {
|
|
||||||
primary = sources.primary.hooks
|
|
||||||
}
|
|
||||||
effective := mergeDroidSettings(primary, sources.settings.hooks)
|
|
||||||
if disabled, _ := effective["hooksDisabled"].(bool); disabled {
|
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
runtimeRoot, err := droidRuntimeRoot()
|
runtimeRoot, err := droidRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
contracts := droidRuntimeContracts(effective, runtimeRoot)
|
contracts := droidRuntimeContracts(effectiveDroidHooks(sources), runtimeRoot)
|
||||||
status.HookConfigured = len(contracts) > 0
|
status.HookConfigured = len(contracts) > 0
|
||||||
if !status.HookConfigured {
|
if !status.HookConfigured {
|
||||||
return status, nil
|
return status, nil
|
||||||
@@ -151,66 +149,3 @@ func (p *DroidPlugin) Status() (PluginStatus, error) {
|
|||||||
status.Installed = status.HookScriptExists
|
status.Installed = status.HookScriptExists
|
||||||
return status, nil
|
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
|
|
||||||
}
|
|
||||||
+254
-138
@@ -3,8 +3,8 @@ package plugins
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,21 +13,22 @@ type droidDocument struct {
|
|||||||
filePath string
|
filePath string
|
||||||
exists bool
|
exists bool
|
||||||
raw []byte
|
raw []byte
|
||||||
|
snapshot *managedFileSnapshot
|
||||||
|
root map[string]any
|
||||||
hooks droidHookSettings
|
hooks droidHookSettings
|
||||||
basePath []any
|
basePath []any
|
||||||
hasHooksContainer bool
|
hasHooksContainer bool
|
||||||
|
hooksDisabled *bool
|
||||||
|
showHookOutput *bool
|
||||||
ownedFile bool
|
ownedFile bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type droidSources struct {
|
type droidSources struct {
|
||||||
rootPath string
|
root *droidDocument
|
||||||
primary *droidDocument
|
legacy *droidDocument
|
||||||
settings *droidDocument
|
settings *droidDocument
|
||||||
}
|
localSettings *droidDocument
|
||||||
|
policy *droidPolicyState
|
||||||
type droidInstallationTargets struct {
|
|
||||||
targets map[string]*droidDocument
|
|
||||||
createdRoot *droidDocument
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type droidRenderedDocument struct {
|
type droidRenderedDocument struct {
|
||||||
@@ -37,72 +38,39 @@ type droidRenderedDocument struct {
|
|||||||
remove bool
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func droidFactoryPaths() (string, string, string, error) {
|
type droidHookBlock struct {
|
||||||
home, err := os.UserHomeDir()
|
field string
|
||||||
if err != nil {
|
filePath string
|
||||||
return "", "", "", fmt.Errorf("resolve home directory: %w", err)
|
label string
|
||||||
}
|
|
||||||
directory := filepath.Join(home, ".factory")
|
|
||||||
return filepath.Join(directory, "hooks.json"),
|
|
||||||
filepath.Join(directory, "hooks", "hooks.json"),
|
|
||||||
filepath.Join(directory, "settings.json"), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readDroidSources() (*droidSources, error) {
|
func parseDroidDocument(
|
||||||
rootPath, legacyPath, settingsPath, err := droidFactoryPaths()
|
exists bool,
|
||||||
if err != nil {
|
filePath, kind string,
|
||||||
return nil, err
|
raw []byte,
|
||||||
}
|
snapshot *managedFileSnapshot,
|
||||||
rootRaw, rootExists, err := readOptionalFile(rootPath, "Factory Droid hooks")
|
) (*droidDocument, error) {
|
||||||
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) {
|
|
||||||
label := droidDocumentLabel(kind, filePath)
|
label := droidDocumentLabel(kind, filePath)
|
||||||
root, err := decodeJSONCObject(raw, label, true)
|
root, err := decodeJSONCObject(raw, label, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
document := &droidDocument{
|
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" {
|
if kind == "settings" || kind == "local-settings" {
|
||||||
hooksValue, hasHooks := root["hooks"]
|
|
||||||
document.basePath = []any{"hooks"}
|
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
|
document.hasHooksContainer = hasHooks
|
||||||
if !hasHooks {
|
if !hasHooks {
|
||||||
document.hooks = droidHookSettings{}
|
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"`)
|
document.hooks, err = readDroidHookSettings(hooksValue, label+` field "hooks"`)
|
||||||
return document, err
|
return document, err
|
||||||
}
|
}
|
||||||
document.hooks, err = readDroidHookSettings(root, label)
|
if hooksValue, hasHooks := root["hooks"]; hasHooks {
|
||||||
document.hasHooksContainer = true
|
document.basePath = []any{"hooks"}
|
||||||
document.ownedFile = bytes.HasPrefix(raw, []byte(droidOwnedFileMarker))
|
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
|
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 {
|
func droidDocumentLabel(kind, filePath string) string {
|
||||||
switch kind {
|
switch kind {
|
||||||
case "settings":
|
case "settings":
|
||||||
return fmt.Sprintf("Factory Droid settings at %s", filePath)
|
return fmt.Sprintf("Factory Droid settings at %s", filePath)
|
||||||
|
case "local-settings":
|
||||||
|
return fmt.Sprintf("Factory Droid local settings at %s", filePath)
|
||||||
case "legacy":
|
case "legacy":
|
||||||
return fmt.Sprintf("Factory Droid legacy hooks at %s", filePath)
|
return fmt.Sprintf("Factory Droid legacy hooks at %s", filePath)
|
||||||
default:
|
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) {
|
func createOwnedDroidDocument(filePath string) (*droidDocument, error) {
|
||||||
return parseDroidDocument(
|
return createDroidDocument(
|
||||||
false, filePath, "hooks", []byte(droidOwnedFileMarker+"\n{}\n"),
|
filePath,
|
||||||
|
"hooks",
|
||||||
|
[]byte(droidOwnedFileMarker+"\n{\n \"hooks\": {}\n}\n"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectDroidInstallationTargets(sources *droidSources) (*droidInstallationTargets, error) {
|
func activeDroidDocument(sources *droidSources) *droidDocument {
|
||||||
selection := &droidInstallationTargets{targets: make(map[string]*droidDocument, len(droidToolEvents))}
|
switch {
|
||||||
for _, event := range droidToolEvents {
|
case sources.root.exists:
|
||||||
switch {
|
return sources.root
|
||||||
case sources.primary != nil && hasDroidHookField(sources.primary.hooks, event):
|
case sources.legacy.exists:
|
||||||
selection.targets[event] = sources.primary
|
return sources.legacy
|
||||||
case sources.settings.hasHooksContainer && hasDroidHookField(sources.settings.hooks, event):
|
case sources.localSettings.hasHooksContainer:
|
||||||
selection.targets[event] = sources.settings
|
return sources.localSettings
|
||||||
case sources.primary != nil:
|
case sources.settings.hasHooksContainer:
|
||||||
selection.targets[event] = sources.primary
|
return sources.settings
|
||||||
case sources.settings.hasHooksContainer:
|
default:
|
||||||
selection.targets[event] = sources.settings
|
return sources.root
|
||||||
default:
|
|
||||||
if selection.createdRoot == nil {
|
|
||||||
created, err := createOwnedDroidDocument(sources.rootPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
selection.createdRoot = created
|
|
||||||
}
|
|
||||||
selection.targets[event] = selection.createdRoot
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return selection, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasDroidHookField(settings droidHookSettings, key string) bool {
|
func effectiveDroidHooks(sources *droidSources) droidHookSettings {
|
||||||
_, exists := settings[key]
|
return activeDroidDocument(sources).hooks
|
||||||
return exists
|
}
|
||||||
|
|
||||||
|
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 {
|
func uniqueDroidDocuments(documents ...*droidDocument) []*droidDocument {
|
||||||
@@ -183,17 +236,13 @@ func uniqueDroidDocuments(documents ...*droidDocument) []*droidDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func droidAdditionsFor(
|
func droidAdditionsFor(
|
||||||
document *droidDocument,
|
document, target *droidDocument,
|
||||||
targets map[string]*droidDocument,
|
|
||||||
groups map[string]map[string]any,
|
groups map[string]map[string]any,
|
||||||
) map[string]map[string]any {
|
) map[string]map[string]any {
|
||||||
additions := map[string]map[string]any{}
|
if sameDroidPath(document.filePath, target.filePath) {
|
||||||
for _, event := range droidToolEvents {
|
return groups
|
||||||
if sameDroidPath(targets[event].filePath, document.filePath) {
|
|
||||||
additions[event] = groups[event]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return additions
|
return map[string]map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderDroidDocument(
|
func renderDroidDocument(
|
||||||
@@ -201,7 +250,14 @@ func renderDroidDocument(
|
|||||||
agentID, runtimeRoot string,
|
agentID, runtimeRoot string,
|
||||||
additions map[string]map[string]any,
|
additions map[string]map[string]any,
|
||||||
) (*droidRenderedDocument, error) {
|
) (*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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -212,28 +268,33 @@ func renderDroidDocument(
|
|||||||
}
|
}
|
||||||
return removals[left].event < removals[right].event
|
return removals[left].event < removals[right].event
|
||||||
})
|
})
|
||||||
for _, removal := range removals {
|
for _, event := range droidToolEvents {
|
||||||
groupPath := appendDroidPath(document.basePath, removal.event, removal.groupIndex)
|
removedEvent := false
|
||||||
if removal.removeGroup {
|
for _, removal := range removals {
|
||||||
if err := editor.remove(groupPath); err != nil {
|
if removal.event != event {
|
||||||
return nil, err
|
continue
|
||||||
}
|
}
|
||||||
continue
|
removedEvent = true
|
||||||
}
|
groupPath := appendDroidPath(document.basePath, removal.event, removal.groupIndex)
|
||||||
sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes)))
|
if removal.removeGroup {
|
||||||
for _, handlerIndex := range removal.handlerIndexes {
|
if err := editor.remove(groupPath); err != nil {
|
||||||
if err := editor.remove(appendDroidPath(groupPath, "hooks", handlerIndex)); err != nil {
|
return nil, err
|
||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if removedEvent {
|
||||||
current, err := parseDroidDocument(document.exists, document.filePath, document.kind, editor.pack())
|
current, parseErr := droidHooksFromRaw(document, editor.pack())
|
||||||
if err != nil {
|
if parseErr != nil {
|
||||||
return nil, err
|
return nil, parseErr
|
||||||
}
|
}
|
||||||
if document.ownedFile {
|
groups, exists := current[event].([]any)
|
||||||
for _, event := range droidToolEvents {
|
|
||||||
groups, exists := current.hooks[event].([]any)
|
|
||||||
if exists && len(groups) == 0 {
|
if exists && len(groups) == 0 {
|
||||||
if err := editor.remove(appendDroidPath(document.basePath, event)); err != nil {
|
if err := editor.remove(appendDroidPath(document.basePath, event)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -246,12 +307,12 @@ func renderDroidDocument(
|
|||||||
if !exists {
|
if !exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
current, err = parseDroidDocument(document.exists, document.filePath, document.kind, editor.pack())
|
current, parseErr := droidHooksFromRaw(document, editor.pack())
|
||||||
if err != nil {
|
if parseErr != nil {
|
||||||
return nil, err
|
return nil, parseErr
|
||||||
}
|
}
|
||||||
eventPath := appendDroidPath(document.basePath, event)
|
eventPath := appendDroidPath(document.basePath, event)
|
||||||
if hasDroidHookField(current.hooks, event) {
|
if _, exists := current[event]; exists {
|
||||||
err = editor.appendArray(eventPath, group)
|
err = editor.appendArray(eventPath, group)
|
||||||
} else {
|
} else {
|
||||||
err = editor.addProperty(document.basePath, event, []any{group})
|
err = editor.addProperty(document.basePath, event, []any{group})
|
||||||
@@ -261,16 +322,77 @@ func renderDroidDocument(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
next := editor.pack()
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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{
|
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
|
}, 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 {
|
func appendDroidPath(base []any, parts ...any) []any {
|
||||||
path := make([]any, 0, len(base)+len(parts))
|
path := make([]any, 0, len(base)+len(parts))
|
||||||
path = append(path, base...)
|
path = append(path, base...)
|
||||||
@@ -278,11 +400,5 @@ func appendDroidPath(base []any, parts ...any) []any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func displayDroidConfigPath(sources *droidSources) string {
|
func displayDroidConfigPath(sources *droidSources) string {
|
||||||
if sources.primary != nil {
|
return activeDroidDocument(sources).filePath
|
||||||
return sources.primary.filePath
|
|
||||||
}
|
|
||||||
if sources.settings.exists && sources.settings.hasHooksContainer {
|
|
||||||
return sources.settings.filePath
|
|
||||||
}
|
|
||||||
return sources.rootPath
|
|
||||||
}
|
}
|
||||||
@@ -6,33 +6,26 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
droidAgentKey = "droid"
|
droidAgentKey = "droid"
|
||||||
droidGuardScript = "guard.js"
|
droidGuardScript = "guard.js"
|
||||||
droidAuditScript = "hook.js"
|
droidAuditScript = "hook.js"
|
||||||
droidHookTimeout = float64(10)
|
droidHookTimeout = float64(10)
|
||||||
droidOwnedFileMarker = "// Managed by Elydora"
|
droidOwnedFileMarker = "// Managed by Elydora"
|
||||||
droidPOSIXApostrophe = `'"'"'`
|
droidPOSIXApostrophe = `'"'"'`
|
||||||
|
droidWindowsExitSuffix = "; exit $LASTEXITCODE"
|
||||||
)
|
)
|
||||||
|
|
||||||
var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"}
|
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 droidHookSettings map[string]any
|
||||||
|
|
||||||
type droidManagedRemoval struct {
|
type droidManagedRemoval struct {
|
||||||
@@ -58,30 +51,20 @@ func readDroidHookSettings(value any, label string) (droidHookSettings, error) {
|
|||||||
if !ok || object == nil {
|
if !ok || object == nil {
|
||||||
return nil, fmt.Errorf("%s must contain a JSON object", label)
|
return nil, fmt.Errorf("%s must contain a JSON object", label)
|
||||||
}
|
}
|
||||||
settings := make(droidHookSettings, len(object))
|
hooks := make(droidHookSettings, len(object))
|
||||||
for key, item := range object {
|
for event, 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)
|
|
||||||
}
|
|
||||||
groups, ok := item.([]any)
|
groups, ok := item.([]any)
|
||||||
if !ok {
|
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 {
|
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
|
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 {
|
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" {
|
if handler["type"] != "command" {
|
||||||
return fmt.Errorf(`%s type must be "command"`, location)
|
return fmt.Errorf(`%s type must be "command"`, location)
|
||||||
}
|
}
|
||||||
if _, ok := handler["command"].(string); !ok {
|
command, ok := handler["command"].(string)
|
||||||
return fmt.Errorf("%s command must be a string", location)
|
if !ok || strings.TrimSpace(command) == "" {
|
||||||
|
return fmt.Errorf("%s command must be a non-empty string", location)
|
||||||
}
|
}
|
||||||
if timeout, exists := handler["timeout"]; exists {
|
if timeout, exists := handler["timeout"]; exists {
|
||||||
number, ok := timeout.(float64)
|
number, ok := timeout.(float64)
|
||||||
if !ok || math.IsNaN(number) || math.IsInf(number, 0) {
|
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 {
|
||||||
return fmt.Errorf("%s timeout must be a finite number", location)
|
return fmt.Errorf("%s timeout must be a positive finite number", location)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -137,19 +121,19 @@ func validateDroidRegexes(settings ...droidHookSettings) error {
|
|||||||
entries := make([]droidRegexEntry, 0)
|
entries := make([]droidRegexEntry, 0)
|
||||||
for _, source := range settings {
|
for _, source := range settings {
|
||||||
for event, value := range source {
|
for event, value := range source {
|
||||||
if droidFlagNames[event] {
|
groups, _ := value.([]any)
|
||||||
continue
|
for index, groupValue := range groups {
|
||||||
}
|
|
||||||
for index, groupValue := range value.([]any) {
|
|
||||||
group := groupValue.(map[string]any)
|
group := groupValue.(map[string]any)
|
||||||
if matcher, ok := group["matcher"].(string); ok && matcher != "" && matcher != "*" {
|
if matcher, ok := group["matcher"].(string); ok && matcher != "" && matcher != "*" {
|
||||||
entries = append(entries, droidRegexEntry{
|
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 {
|
if expression, ok := group["commandRegex"].(string); ok {
|
||||||
entries = append(entries, droidRegexEntry{
|
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 {
|
func buildDroidCommand(nodePath, scriptPath string) string {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return quoteDroidWindowsArgument(nodePath) + " " + quoteDroidWindowsArgument(scriptPath)
|
return "& " + quoteDroidPowerShellArgument(nodePath) + " " +
|
||||||
|
quoteDroidPowerShellArgument(scriptPath) + droidWindowsExitSuffix
|
||||||
}
|
}
|
||||||
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func quoteDroidWindowsArgument(value string) string {
|
func quoteDroidPowerShellArgument(value string) string {
|
||||||
quoted := quoteWindowsArgument(value)
|
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||||
if strings.HasPrefix(quoted, `"`) {
|
|
||||||
return quoted
|
|
||||||
}
|
|
||||||
return `"` + quoted + `"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDroidGroup(nodePath, scriptPath string) map[string]any {
|
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) {
|
func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) {
|
||||||
reader := readDroidPOSIXArgument
|
if runtime.GOOS != "windows" {
|
||||||
if runtime.GOOS == "windows" {
|
return parseDroidPOSIXCommand(command)
|
||||||
reader = readDroidWindowsArgument
|
|
||||||
}
|
}
|
||||||
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] != ' ' {
|
if !ok || next >= len(command) || command[next] != ' ' {
|
||||||
return "", "", false
|
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 != ""
|
return executable, script, ok && end == len(command) && executable != "" && script != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func readDroidWindowsArgument(command string, start int) (string, int, bool) {
|
func parseDroidPowerShellCommand(command string) (string, string, bool) {
|
||||||
if start >= len(command) || command[start] != '"' {
|
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
|
return "", start, false
|
||||||
}
|
}
|
||||||
var value strings.Builder
|
var value strings.Builder
|
||||||
for index := start + 1; index < len(command); index++ {
|
for index := start + 1; index < len(command); index++ {
|
||||||
if command[index] == '\\' && index+1 < len(command) && command[index+1] == '"' {
|
if command[index] != '\'' {
|
||||||
value.WriteByte('"')
|
value.WriteByte(command[index])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if index+1 < len(command) && command[index+1] == '\'' {
|
||||||
|
value.WriteByte('\'')
|
||||||
index++
|
index++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if command[index] == '"' {
|
return value.String(), index + 1, true
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
}
|
}
|
||||||
return "", start, false
|
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) {
|
func readDroidPOSIXArgument(command string, start int) (string, int, bool) {
|
||||||
if start >= len(command) || command[start] != '\'' {
|
if start >= len(command) || command[start] != '\'' {
|
||||||
return "", start, false
|
return "", start, false
|
||||||
@@ -293,14 +316,14 @@ func sameDroidAgentID(left, right string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func droidRuntimeRoot() (string, error) {
|
func droidRuntimeRoot() (string, error) {
|
||||||
home, err := os.UserHomeDir()
|
return AgentRuntimeRoot()
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".elydora"), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != droidHookTimeout {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -308,9 +331,9 @@ func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot string)
|
|||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
executable, scriptPath, ok := parseDroidCommand(command)
|
executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...)
|
||||||
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) || !isDroidNodeExecutable(executable) ||
|
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
|
||||||
!sameDroidFileName(filepath.Base(scriptPath), scriptName) {
|
!isDroidNodeExecutable(executable) || !sameDroidFileName(filepath.Base(scriptPath), scriptName) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
agentDirectory := filepath.Dir(scriptPath)
|
||||||
@@ -323,10 +346,7 @@ func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot string)
|
|||||||
|
|
||||||
func isDroidNodeExecutable(path string) bool {
|
func isDroidNodeExecutable(path string) bool {
|
||||||
name := filepath.Base(path)
|
name := filepath.Base(path)
|
||||||
if runtime.GOOS == "windows" {
|
return name == "node" || strings.EqualFold(name, "node.exe")
|
||||||
return strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
|
||||||
return name == "node"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameDroidFileName(left, right string) bool {
|
func sameDroidFileName(left, right string) bool {
|
||||||
@@ -347,7 +367,9 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
|
|||||||
handlers := group["hooks"].([]any)
|
handlers := group["hooks"].([]any)
|
||||||
indexes := make([]int, 0)
|
indexes := make([]int, 0)
|
||||||
for handlerIndex, handlerValue := range handlers {
|
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)) {
|
if managed && (agentID == "" || sameDroidAgentID(managedID, agentID)) {
|
||||||
indexes = append(indexes, handlerIndex)
|
indexes = append(indexes, handlerIndex)
|
||||||
}
|
}
|
||||||
@@ -357,8 +379,11 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
|
|||||||
}
|
}
|
||||||
_, hasMatcher := group["matcher"]
|
_, hasMatcher := group["matcher"]
|
||||||
_, hasHooks := group["hooks"]
|
_, hasHooks := group["hooks"]
|
||||||
exactGroup := len(group) == 2 && hasMatcher && hasHooks && group["matcher"] == "*" && len(indexes) == len(handlers)
|
exactGroup := len(group) == 2 && hasMatcher && hasHooks &&
|
||||||
removals = append(removals, droidManagedRemoval{contract.event, groupIndex, indexes, exactGroup})
|
group["matcher"] == "*" && len(indexes) == len(handlers)
|
||||||
|
removals = append(removals, droidManagedRemoval{
|
||||||
|
contract.event, groupIndex, indexes, exactGroup,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return removals
|
return removals
|
||||||
@@ -367,20 +392,24 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
|
|||||||
func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []droidRuntimeContract {
|
func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []droidRuntimeContract {
|
||||||
guards := managedDroidIDs(settings, "PreToolUse", droidGuardScript, runtimeRoot)
|
guards := managedDroidIDs(settings, "PreToolUse", droidGuardScript, runtimeRoot)
|
||||||
audits := managedDroidIDs(settings, "PostToolUse", droidAuditScript, runtimeRoot)
|
audits := managedDroidIDs(settings, "PostToolUse", droidAuditScript, runtimeRoot)
|
||||||
contracts := make([]droidRuntimeContract, 0)
|
guardIDs := make([]string, 0, len(guards))
|
||||||
for guardID := range guards {
|
for agentID := range guards {
|
||||||
matched := false
|
guardIDs = append(guardIDs, agentID)
|
||||||
|
}
|
||||||
|
sort.Strings(guardIDs)
|
||||||
|
contracts := make([]droidRuntimeContract, 0, len(guardIDs))
|
||||||
|
for _, guardID := range guardIDs {
|
||||||
for auditID := range audits {
|
for auditID := range audits {
|
||||||
if sameDroidAgentID(guardID, auditID) {
|
if !sameDroidAgentID(guardID, auditID) {
|
||||||
matched = true
|
continue
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if matched {
|
|
||||||
root := filepath.Join(runtimeRoot, guardID)
|
root := filepath.Join(runtimeRoot, guardID)
|
||||||
contracts = append(contracts, droidRuntimeContract{
|
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
|
return contracts
|
||||||
@@ -390,22 +419,14 @@ func managedDroidIDs(settings droidHookSettings, event, script, runtimeRoot stri
|
|||||||
ids := map[string]bool{}
|
ids := map[string]bool{}
|
||||||
groups, _ := settings[event].([]any)
|
groups, _ := settings[event].([]any)
|
||||||
for _, groupValue := range groups {
|
for _, groupValue := range groups {
|
||||||
for _, handlerValue := range groupValue.(map[string]any)["hooks"].([]any) {
|
group := groupValue.(map[string]any)
|
||||||
if agentID, ok := managedDroidAgentID(handlerValue.(map[string]any), script, runtimeRoot); ok {
|
handlers := group["hooks"].([]any)
|
||||||
ids[agentID] = true
|
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
|
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
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+122
-45
@@ -1,62 +1,139 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
func prepareRenderedDroidChange(rendered *droidRenderedDocument) (*fileChange, error) {
|
type droidFactoryConfigPaths struct {
|
||||||
if rendered == nil || !rendered.changed {
|
directory string
|
||||||
return nil, nil
|
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"
|
directory := filepath.Join(home, ".factory")
|
||||||
if rendered.document.kind == "legacy" {
|
legacyDirectory := filepath.Join(directory, "hooks")
|
||||||
label = "Factory Droid legacy hooks"
|
return &droidFactoryConfigPaths{
|
||||||
} else if rendered.document.kind == "settings" {
|
directory: directory,
|
||||||
label = "Factory Droid settings"
|
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(
|
return parseDroidDocument(
|
||||||
rendered.document.filePath,
|
true,
|
||||||
label,
|
filePath,
|
||||||
rendered.document.raw,
|
kind,
|
||||||
rendered.document.exists,
|
snapshot.contents,
|
||||||
rendered.next,
|
snapshot,
|
||||||
os.FileMode(0600),
|
|
||||||
rendered.remove,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) {
|
func readDroidSources() (*droidSources, error) {
|
||||||
for _, contract := range contracts {
|
paths, err := droidFactoryPaths()
|
||||||
configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json")
|
if err != nil {
|
||||||
raw, exists, err := readOptionalFile(configPath, "Elydora runtime config")
|
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 {
|
if err != nil {
|
||||||
return false, err
|
return nil, 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 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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,81 +1,101 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDroidUninstallPreservesMixedGroupsAndLookalikes(t *testing.T) {
|
func TestDroidStatusSurfacesMalformedRuntimeIdentity(t *testing.T) {
|
||||||
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
tests := []struct {
|
||||||
installDroidFixture(t, fixture)
|
name string
|
||||||
root := readDroidTestObject(t, fixture.configPath)
|
mutate func(*droidFixture)
|
||||||
group := droidManagedGroup(t, root["PreToolUse"], fixture.guardPath)
|
pattern string
|
||||||
command := droidManagedHandler(t, []any{group}, fixture.guardPath)["command"].(string)
|
}{
|
||||||
group["hooks"] = append(requireDroidArray(t, group["hooks"]), map[string]any{
|
{
|
||||||
"type": "command", "command": "user-command",
|
"malformed config",
|
||||||
})
|
func(fixture *droidFixture) {
|
||||||
group["owner"] = "user"
|
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||||
root["PreToolUse"] = append(requireDroidArray(t, root["PreToolUse"]),
|
t.Fatalf("corrupt runtime config: %v", err)
|
||||||
map[string]any{
|
}
|
||||||
"matcher": "*",
|
},
|
||||||
"hooks": []any{map[string]any{
|
"parse Elydora runtime config",
|
||||||
"type": "command", "command": strings.Replace(command, droidGuardScript, droidGuardScript+".backup", 1), "timeout": 10,
|
|
||||||
}},
|
|
||||||
},
|
},
|
||||||
map[string]any{
|
{
|
||||||
"matcher": "*",
|
"unsupported config field",
|
||||||
"hooks": []any{map[string]any{
|
func(fixture *droidFixture) {
|
||||||
"type": "command", "command": strings.Replace(command, "agent-1", "agent-10", 1), "timeout": 10,
|
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 _, test := range tests {
|
||||||
for _, marker := range []string{"user-command", droidGuardScript + ".backup", "agent-10"} {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
if !strings.Contains(remainingRaw, marker) {
|
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
||||||
t.Fatalf("uninstall removed lookalike %q", marker)
|
installDroidFixture(t, fixture)
|
||||||
}
|
test.mutate(fixture)
|
||||||
}
|
_, err := fixture.plugin.Status()
|
||||||
remaining := readDroidTestObject(t, fixture.configPath)
|
if err == nil || !strings.Contains(err.Error(), test.pattern) {
|
||||||
if len(requireDroidArray(t, remaining["PostToolUse"])) != 0 {
|
t.Fatalf("status error = %v, want %q", err, test.pattern)
|
||||||
t.Fatalf("PostToolUse after uninstall = %#v", remaining["PostToolUse"])
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) {
|
func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
hooks *string
|
root *string
|
||||||
legacy *string
|
legacy *string
|
||||||
settings *string
|
settings *string
|
||||||
|
local *string
|
||||||
pattern string
|
pattern string
|
||||||
}{
|
}{
|
||||||
{"malformed hooks", droidString("{ malformed"), nil, nil, "parse Factory Droid hooks"},
|
{"malformed root", droidString("{ malformed"), nil, nil, nil, "parse Factory Droid hooks"},
|
||||||
{"non-object hooks", droidJSON([]any{}), nil, nil, "JSON object"},
|
{"non-object root", droidJSON([]any{}), nil, nil, nil, "JSON object"},
|
||||||
{"duplicate event", droidString(`{ "PreToolUse": [], "PreToolUse": [] }`), nil, nil, "duplicate"},
|
{"duplicate event", droidString(`{"hooks":{"PreToolUse":[],"PreToolUse":[]}}`), nil, nil, nil, "duplicate"},
|
||||||
{"nested duplicate", droidString(`{ "PreToolUse": [{ "hooks": [], "hooks": [] }] }`), nil, nil, "duplicate"},
|
{"null event", droidString(`{"hooks":{"PreToolUse":null}}`), nil, nil, nil, "must be an array"},
|
||||||
{"null event", droidString(`{ "PreToolUse": null }`), nil, nil, "must be an array"},
|
{"null group", droidString(`{"hooks":{"PreToolUse":[null]}}`), nil, nil, nil, "must be an object"},
|
||||||
{"null group", droidString(`{ "PreToolUse": [null] }`), nil, nil, "must be an object"},
|
{
|
||||||
{"invalid matcher", droidString(`{ "PreToolUse": [{ "matcher": "[", "hooks": [] }] }`), nil, nil, "regular expression"},
|
"invalid matcher",
|
||||||
{"invalid handler", droidString(`{ "PreToolUse": [{ "hooks": [{ "type": "command", "command": 1 }] }] }`), nil, nil, "must be a string"},
|
droidString(`{"hooks":{"PreToolUse":[{"matcher":"[","hooks":[]}]}}`),
|
||||||
{"invalid flag", droidString(`{ "hooksDisabled": "yes" }`), nil, nil, "must be a boolean"},
|
nil, nil, nil, "regular expression",
|
||||||
{"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"},
|
"empty handler command",
|
||||||
{"null settings hooks", nil, nil, droidString(`{ "hooks": null }`), "JSON object"},
|
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 {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
fixture := prepareDroidFixture(t, droidFixtureOptions{
|
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)
|
originals := existingDroidSources(t, fixture)
|
||||||
err := fixture.plugin.Install(fixture.config)
|
err := fixture.plugin.Install(fixture.config)
|
||||||
@@ -87,41 +107,57 @@ func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) {
|
|||||||
t.Fatalf("failed install changed %s", path)
|
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)
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
mutate func(*droidFixture)
|
mutate func(*droidFixture)
|
||||||
pattern string
|
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",
|
"guard outside managed directory",
|
||||||
mutate: func(fixture *droidFixture) {
|
func(f *droidFixture) { f.config.GuardScriptPath = filepath.Join(f.homeDir, "guard.js") },
|
||||||
if err := os.Remove(fixture.guardPath); err != nil {
|
"managed agent directory",
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -132,111 +168,145 @@ func TestDroidInstallValidatesManagedPathsBeforeWrites(t *testing.T) {
|
|||||||
if err == nil || !strings.Contains(err.Error(), test.pattern) {
|
if err == nil || !strings.Contains(err.Error(), test.pattern) {
|
||||||
t.Fatalf("install error = %v, want substring %q", err, 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, fixture.configPath)
|
||||||
requireMissingDroidFile(t, path)
|
requireMissingDroidFile(t, fixture.runtimeConfig)
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDroidTransactionRollsBackAllFilesAndCleansStaging(t *testing.T) {
|
func TestDroidOrphanedRuntimeIsPreservedWithoutVerifiableIdentity(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) {
|
|
||||||
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
||||||
installDroidFixture(t, fixture)
|
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
|
||||||
t.Setenv("PATH", "")
|
t.Fatalf("create orphaned runtime: %v", err)
|
||||||
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 {
|
if err := os.WriteFile(fixture.guardPath, []byte("orphaned guard\n"), 0700); err != nil {
|
||||||
t.Fatalf("uninstall without PATH: %v", err)
|
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)
|
requireMissingDroidFile(t, fixture.configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDroidStatusSurfacesMalformedRuntimeConfig(t *testing.T) {
|
func TestDroidUninstallPreservesUsersAndExactOwnership(t *testing.T) {
|
||||||
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{
|
||||||
|
"hooks": map[string]any{"Notification": []any{}},
|
||||||
|
})})
|
||||||
installDroidFixture(t, fixture)
|
installDroidFixture(t, fixture)
|
||||||
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
root := readDroidTestObject(t, fixture.configPath)
|
||||||
t.Fatalf("corrupt runtime config: %v", err)
|
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") {
|
if err := fixture.plugin.Uninstall(agentID); err != nil {
|
||||||
t.Fatalf("status error = %v", err)
|
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) {
|
func TestDroidUninstallRemovesOwnedEmptyFileAndKeepsAbsence(t *testing.T) {
|
||||||
t.Helper()
|
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
installDroidFixture(t, fixture)
|
||||||
if err != nil {
|
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
|
||||||
t.Fatalf("encode %s: %v", path, err)
|
t.Fatalf("uninstall Factory Droid hooks: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, append(encoded, '\n'), 0600); err != nil {
|
requireMissingDroidFile(t, fixture.configPath)
|
||||||
t.Fatalf("write %s: %v", path, err)
|
|
||||||
|
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 {
|
func existingDroidSources(t *testing.T, fixture *droidFixture) map[string]string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
result := map[string]string{}
|
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 {
|
if raw, err := os.ReadFile(path); err == nil {
|
||||||
result[path] = string(raw)
|
result[path] = string(raw)
|
||||||
} else if !errors.Is(err, os.ErrNotExist) {
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -245,19 +315,3 @@ func existingDroidSources(t *testing.T, fixture *droidFixture) map[string]string
|
|||||||
}
|
}
|
||||||
return result
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+298
-193
@@ -2,12 +2,18 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDroidRegistryUsesOfficialGlobalHookSource(t *testing.T) {
|
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" {
|
if entry.Name != "Factory Droid" || entry.ConfigDir != "~/.factory" || entry.ConfigFile != "hooks.json" {
|
||||||
t.Fatalf("Factory Droid registry entry = %#v", entry)
|
t.Fatalf("Factory Droid registry entry = %#v", entry)
|
||||||
}
|
}
|
||||||
if _, ok := NewPlugin(droidAgentKey).(*DroidPlugin); !ok {
|
plugin, ok := NewPlugin(droidAgentKey).(*DroidPlugin)
|
||||||
t.Fatal("Factory Droid plugin is not registered")
|
if !ok || !plugin.ManagesGuardRuntime() {
|
||||||
|
t.Fatal("Factory Droid plugin is not registered as a guard runtime manager")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDroidInstallPreservesJSONCAndUsesPerEventPrecedence(t *testing.T) {
|
func TestDroidInstallWritesCurrentContainerAndCompleteRuntime(t *testing.T) {
|
||||||
hooks := `{
|
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
||||||
// 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{}}}),
|
|
||||||
})
|
|
||||||
reader, writer, err := os.Pipe()
|
reader, writer, err := os.Pipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create output pipe: %v", err)
|
t.Fatalf("create output pipe: %v", err)
|
||||||
@@ -144,120 +43,326 @@ func TestDroidInstallReportsEventSourcesAndReviewCommand(t *testing.T) {
|
|||||||
if installErr != nil || closeErr != nil || readErr != nil {
|
if installErr != nil || closeErr != nil || readErr != nil {
|
||||||
t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr)
|
t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr)
|
||||||
}
|
}
|
||||||
text := string(output)
|
if !strings.Contains(string(output), fixture.configPath) || !strings.Contains(string(output), "run /hooks") {
|
||||||
for _, marker := range []string{fixture.configPath, fixture.settingsPath, "PreToolUse", "PostToolUse", "/hooks"} {
|
t.Fatalf("install output = %s", output)
|
||||||
if !strings.Contains(text, marker) {
|
}
|
||||||
t.Fatalf("install output is missing %q: %s", marker, text)
|
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) {
|
func TestDroidRootHookFileHasWholeSourcePrecedence(t *testing.T) {
|
||||||
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
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)
|
installDroidFixture(t, fixture)
|
||||||
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
if !strings.Contains(readDroidTestFile(t, fixture.configPath), "active root source") {
|
||||||
encodedPath, err := json.Marshal(capturePath)
|
t.Fatal("root JSONC comment was lost")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal capture path: %v", err)
|
|
||||||
}
|
}
|
||||||
captureScript := "const fs = require('node:fs'); const chunks = []; " +
|
hooks := droidCurrentHooks(t, fixture.configPath)
|
||||||
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
|
pre := requireDroidArray(t, hooks["PreToolUse"])
|
||||||
"process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n"
|
userCommand := requireDroidObject(t, requireDroidArray(t, requireDroidObject(t, pre[0])["hooks"])[0])["command"]
|
||||||
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
|
if userCommand != "root-user" {
|
||||||
t.Fatalf("write capture audit runtime: %v", err)
|
t.Fatalf("root user command = %#v", userCommand)
|
||||||
}
|
}
|
||||||
root := readDroidTestObject(t, fixture.configPath)
|
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath))
|
||||||
guard := droidManagedHandler(t, root["PreToolUse"], fixture.guardPath)
|
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath))
|
||||||
audit := droidManagedHandler(t, root["PostToolUse"], fixture.hookPath)
|
if readDroidTestFile(t, fixture.settingsPath) != settings {
|
||||||
prePayload := `{"session_id":"session-1","cwd":"C:/workspace","permission_mode":"auto-high","hook_event_name":"PreToolUse","tool_name":"Execute","tool_input":{"command":"echo test"}}`
|
t.Fatal("inactive settings source changed")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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{
|
fixture := prepareDroidFixture(t, droidFixtureOptions{
|
||||||
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
|
settings: settings,
|
||||||
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
|
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)
|
installDroidFixture(t, fixture)
|
||||||
status, err := fixture.plugin.Status()
|
status, err := fixture.plugin.Status()
|
||||||
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
||||||
t.Fatalf("installed status = %#v, %v", status, err)
|
t.Fatalf("installed status = %#v, %v", status, err)
|
||||||
}
|
}
|
||||||
if err := os.Remove(fixture.hookPath); err != nil {
|
if err := os.WriteFile(fixture.hookPath, []byte("tampered\n"), 0700); err != nil {
|
||||||
t.Fatalf("remove audit runtime: %v", err)
|
t.Fatalf("tamper audit runtime: %v", err)
|
||||||
}
|
}
|
||||||
status, err = fixture.plugin.Status()
|
status, err = fixture.plugin.Status()
|
||||||
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
||||||
t.Fatalf("missing-runtime status = %#v, %v", status, err)
|
t.Fatalf("tampered 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDroidUninstallPreservesUserSourcesAndExactOwnership(t *testing.T) {
|
func TestDroidStatusUsesOneWholeActiveSource(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"
|
|
||||||
fixture := prepareDroidFixture(t, droidFixtureOptions{
|
fixture := prepareDroidFixture(t, droidFixtureOptions{
|
||||||
hooks: droidString(hooks), settings: droidString(settings),
|
settings: droidJSON(map[string]any{"hooks": map[string]any{}}),
|
||||||
})
|
})
|
||||||
installDroidFixture(t, fixture)
|
installDroidFixture(t, fixture)
|
||||||
if err := fixture.plugin.Uninstall("agent-10"); err != nil {
|
settingsHooks := requireDroidObject(t, readDroidTestObject(t, fixture.settingsPath)["hooks"])
|
||||||
t.Fatalf("uninstall lookalike agent: %v", err)
|
writeDroidTestObject(t, fixture.configPath, map[string]any{"hooks": map[string]any{
|
||||||
}
|
"PreToolUse": settingsHooks["PreToolUse"],
|
||||||
droidManagedHandler(t, readDroidTestObject(t, fixture.configPath)["PreToolUse"], fixture.guardPath)
|
}})
|
||||||
agentID := droidTestAgentID
|
status, err := fixture.plugin.Status()
|
||||||
if runtime.GOOS == "windows" {
|
if err != nil || status.HookConfigured || status.Installed {
|
||||||
agentID = "AGENT-1"
|
t.Fatalf("split-source status = %#v, %v", status, err)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDroidUninstallDeletesOnlyOwnedEmptyHookFile(t *testing.T) {
|
func TestDroidStatusAndUninstallDoNotRequireNodeResolution(t *testing.T) {
|
||||||
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
||||||
installDroidFixture(t, fixture)
|
installDroidFixture(t, fixture)
|
||||||
if !strings.HasPrefix(readDroidTestFile(t, fixture.configPath), droidOwnedFileMarker) {
|
t.Setenv("PATH", "")
|
||||||
t.Fatal("created hook source is missing its ownership marker")
|
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 {
|
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)
|
requireMissingDroidFile(t, fixture.configPath)
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -12,33 +13,45 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
const droidTestAgentID = "agent-1"
|
const (
|
||||||
|
droidTestAgentID = "agent-1"
|
||||||
|
droidTestPrivateKey = "DQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0"
|
||||||
|
)
|
||||||
|
|
||||||
type droidFixtureOptions struct {
|
type droidFixtureOptions struct {
|
||||||
hooks *string
|
root *string
|
||||||
legacyHooks *string
|
legacy *string
|
||||||
settings *string
|
settings *string
|
||||||
skipGuard bool
|
localSettings *string
|
||||||
|
projectSettings *string
|
||||||
|
projectLocalSettings *string
|
||||||
|
baseURL string
|
||||||
|
agentID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type droidFixture struct {
|
type droidFixture struct {
|
||||||
plugin *DroidPlugin
|
plugin *DroidPlugin
|
||||||
config InstallConfig
|
config InstallConfig
|
||||||
homeDir string
|
homeDir string
|
||||||
workspaceDir string
|
workspaceDir string
|
||||||
factoryDir string
|
factoryDir string
|
||||||
configPath string
|
configPath string
|
||||||
legacyPath string
|
legacyPath string
|
||||||
settingsPath string
|
settingsPath string
|
||||||
agentDir string
|
localSettingsPath string
|
||||||
guardPath string
|
projectSettingsPath string
|
||||||
hookPath string
|
projectLocalSettingsPath string
|
||||||
runtimeConfig string
|
systemSettingsPath string
|
||||||
privateKey string
|
agentDir string
|
||||||
|
guardPath string
|
||||||
|
hookPath string
|
||||||
|
runtimeConfig string
|
||||||
|
privateKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type droidCommandResult struct {
|
type droidCommandResult struct {
|
||||||
exitCode int
|
exitCode int
|
||||||
|
stdout string
|
||||||
stderr string
|
stderr string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,51 +70,78 @@ func droidJSON(value any) *string {
|
|||||||
func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixture {
|
func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rootDir := t.TempDir()
|
rootDir := t.TempDir()
|
||||||
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
|
homeDir := filepath.Join(rootDir, "home with spaces and 'quote %DROID%")
|
||||||
workspaceDir := filepath.Join(rootDir, "workspace")
|
workspaceDir := filepath.Join(rootDir, "workspace with spaces")
|
||||||
factoryDir := filepath.Join(homeDir, ".factory")
|
factoryDir := filepath.Join(homeDir, ".factory")
|
||||||
configPath := filepath.Join(factoryDir, "hooks.json")
|
configPath := filepath.Join(factoryDir, "hooks.json")
|
||||||
legacyPath := filepath.Join(factoryDir, "hooks", "hooks.json")
|
legacyPath := filepath.Join(factoryDir, "hooks", "hooks.json")
|
||||||
settingsPath := filepath.Join(factoryDir, "settings.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)
|
guardPath := filepath.Join(agentDir, droidGuardScript)
|
||||||
hookPath := filepath.Join(agentDir, droidAuditScript)
|
hookPath := filepath.Join(agentDir, droidAuditScript)
|
||||||
for _, directory := range []string{workspaceDir, agentDir} {
|
if err := os.MkdirAll(filepath.Join(workspaceDir, ".git"), 0755); err != nil {
|
||||||
if err := os.MkdirAll(directory, 0755); err != nil {
|
t.Fatalf("create fixture workspace: %v", err)
|
||||||
t.Fatalf("create fixture directory %s: %v", directory, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if !options.skipGuard {
|
writeOptionalDroidFile(t, configPath, options.root)
|
||||||
guard := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n"
|
writeOptionalDroidFile(t, legacyPath, options.legacy)
|
||||||
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, settingsPath, options.settings)
|
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("HOME", homeDir)
|
||||||
t.Setenv("USERPROFILE", 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{
|
return &droidFixture{
|
||||||
plugin: &DroidPlugin{},
|
plugin: &DroidPlugin{},
|
||||||
homeDir: homeDir,
|
homeDir: homeDir,
|
||||||
workspaceDir: workspaceDir,
|
workspaceDir: workspaceDir,
|
||||||
factoryDir: factoryDir,
|
factoryDir: factoryDir,
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
legacyPath: legacyPath,
|
legacyPath: legacyPath,
|
||||||
settingsPath: settingsPath,
|
settingsPath: settingsPath,
|
||||||
agentDir: agentDir,
|
localSettingsPath: localSettingsPath,
|
||||||
guardPath: guardPath,
|
projectSettingsPath: projectSettingsPath,
|
||||||
hookPath: hookPath,
|
projectLocalSettingsPath: projectLocalSettingsPath,
|
||||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
systemSettingsPath: systemSettingsPath,
|
||||||
privateKey: filepath.Join(agentDir, "private.key"),
|
agentDir: agentDir,
|
||||||
|
guardPath: guardPath,
|
||||||
|
hookPath: hookPath,
|
||||||
|
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||||
|
privateKey: filepath.Join(agentDir, "private.key"),
|
||||||
config: InstallConfig{
|
config: InstallConfig{
|
||||||
AgentName: droidAgentKey,
|
AgentName: droidAgentKey,
|
||||||
OrgID: "org-1",
|
OrgID: "org-1",
|
||||||
AgentID: droidTestAgentID,
|
AgentID: agentID,
|
||||||
PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE",
|
PrivateKey: droidTestPrivateKey,
|
||||||
KID: "kid-1",
|
KID: "kid-1",
|
||||||
BaseURL: "https://api.elydora.test",
|
Token: "token-1",
|
||||||
|
BaseURL: baseURL,
|
||||||
GuardScriptPath: guardPath,
|
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) {
|
func installDroidFixture(t *testing.T, fixture *droidFixture) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
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) {
|
func requireMissingDroidFile(t *testing.T, path string) {
|
||||||
t.Helper()
|
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)
|
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 {
|
func readDroidTestObject(t *testing.T, path string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
standard, err := standardizeJSONC(
|
standard, err := standardizeJSONC(
|
||||||
[]byte(readDroidTestFile(t, path)), "Factory Droid test source", true,
|
[]byte(readDroidTestFile(t, path)),
|
||||||
|
"Factory Droid test source",
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("standardize %s: %v", path, err)
|
t.Fatalf("standardize %s: %v", path, err)
|
||||||
@@ -158,6 +205,11 @@ func readDroidTestObject(t *testing.T, path string) map[string]any {
|
|||||||
return object
|
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 {
|
func requireDroidObject(t *testing.T, value any) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
object, ok := value.(map[string]any)
|
object, ok := value.(map[string]any)
|
||||||
@@ -176,24 +228,6 @@ func requireDroidArray(t *testing.T, value any) []any {
|
|||||||
return array
|
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 {
|
func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, groupValue := range requireDroidArray(t, groupsValue) {
|
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"]) {
|
for _, handlerValue := range requireDroidArray(t, group["hooks"]) {
|
||||||
handler := requireDroidObject(t, handlerValue)
|
handler := requireDroidObject(t, handlerValue)
|
||||||
command, _ := handler["command"].(string)
|
command, _ := handler["command"].(string)
|
||||||
if strings.Contains(command, scriptPath) {
|
_, configuredPath, managed := parseDroidCommand(command, true)
|
||||||
|
if managed && sameDroidPath(configuredPath, scriptPath) {
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,30 +245,101 @@ func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[str
|
|||||||
return nil
|
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 {
|
func runDroidCommand(t *testing.T, command, homeDir, payload string) droidCommandResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var process *exec.Cmd
|
var process *exec.Cmd
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
commandFile := filepath.Join(t.TempDir(), "run-droid-hook.cmd")
|
process = exec.Command(
|
||||||
if err := os.WriteFile(commandFile, []byte("@echo off\r\n"+command+"\r\n"), 0600); err != nil {
|
"powershell.exe",
|
||||||
t.Fatalf("write command file: %v", err)
|
"-NoLogo",
|
||||||
}
|
"-NoProfile",
|
||||||
process = exec.Command("cmd.exe", "/d", "/c", commandFile)
|
"-NonInteractive",
|
||||||
|
"-Command",
|
||||||
|
command,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
process = exec.Command("sh", "-c", command)
|
process = exec.Command("sh", "-c", command)
|
||||||
}
|
}
|
||||||
process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
|
process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
|
||||||
process.Stdin = bytes.NewBufferString(payload)
|
process.Stdin = bytes.NewBufferString(payload)
|
||||||
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
process.Stdout = &stdout
|
||||||
process.Stderr = &stderr
|
process.Stderr = &stderr
|
||||||
err := process.Run()
|
err := process.Run()
|
||||||
|
result := droidCommandResult{stdout: stdout.String(), stderr: stderr.String()}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return droidCommandResult{stderr: stderr.String()}
|
return result
|
||||||
}
|
}
|
||||||
var exitError *exec.ExitError
|
var exitError *exec.ExitError
|
||||||
if errors.As(err, &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)
|
t.Fatalf("run Factory Droid hook command: %v", err)
|
||||||
return droidCommandResult{}
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user