369 lines
14 KiB
Go
369 lines
14 KiB
Go
package plugins
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDroidRegistryUsesOfficialGlobalHookSource(t *testing.T) {
|
|
entry := SupportedAgents[droidAgentKey]
|
|
if entry.Name != "Factory Droid" || entry.ConfigDir != "~/.factory" || entry.ConfigFile != "hooks.json" {
|
|
t.Fatalf("Factory Droid registry entry = %#v", entry)
|
|
}
|
|
plugin, ok := NewPlugin(droidAgentKey).(*DroidPlugin)
|
|
if !ok || !plugin.ManagesGuardRuntime() {
|
|
t.Fatal("Factory Droid plugin is not registered as a guard runtime manager")
|
|
}
|
|
}
|
|
|
|
func TestDroidInstallWritesCurrentContainerAndCompleteRuntime(t *testing.T) {
|
|
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
|
reader, writer, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("create output pipe: %v", err)
|
|
}
|
|
original := os.Stdout
|
|
os.Stdout = writer
|
|
installErr := fixture.plugin.Install(fixture.config)
|
|
os.Stdout = original
|
|
closeErr := writer.Close()
|
|
output, readErr := io.ReadAll(reader)
|
|
_ = reader.Close()
|
|
if installErr != nil || closeErr != nil || readErr != nil {
|
|
t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr)
|
|
}
|
|
if !strings.Contains(string(output), fixture.configPath) || !strings.Contains(string(output), "run /hooks") {
|
|
t.Fatalf("install output = %s", output)
|
|
}
|
|
source := readDroidTestFile(t, fixture.configPath)
|
|
if !strings.HasPrefix(source, droidOwnedFileMarker+"\n") {
|
|
t.Fatalf("managed source header is missing: %s", source)
|
|
}
|
|
hooks := droidCurrentHooks(t, fixture.configPath)
|
|
guardGroup := droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath)
|
|
auditGroup := droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath)
|
|
requireDroidNativeGroup(t, guardGroup)
|
|
requireDroidNativeGroup(t, auditGroup)
|
|
guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string)
|
|
if runtime.GOOS == "windows" &&
|
|
(!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, droidWindowsExitSuffix)) {
|
|
t.Fatalf("Windows Factory command = %q", guardCommand)
|
|
}
|
|
runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig)
|
|
expectedConfig := map[string]any{
|
|
"org_id": "org-1",
|
|
"agent_id": droidTestAgentID,
|
|
"kid": "kid-1",
|
|
"base_url": "http://127.0.0.1:9",
|
|
"agent_name": droidAgentKey,
|
|
"token": "token-1",
|
|
}
|
|
if !reflect.DeepEqual(runtimeConfig, expectedConfig) {
|
|
t.Fatalf("runtime config = %#v", runtimeConfig)
|
|
}
|
|
if readDroidTestFile(t, fixture.privateKey) != droidTestPrivateKey {
|
|
t.Fatal("private key content changed")
|
|
}
|
|
if !strings.Contains(readDroidTestFile(t, fixture.guardPath), `const AGENT_NAME = "droid"`) {
|
|
t.Fatal("guard runtime identity is missing")
|
|
}
|
|
if !strings.Contains(readDroidTestFile(t, fixture.hookPath), "const NATIVE_PAYLOAD = true") {
|
|
t.Fatal("audit runtime is missing native payload mode")
|
|
}
|
|
paths := []string{
|
|
fixture.configPath,
|
|
fixture.guardPath,
|
|
fixture.hookPath,
|
|
fixture.runtimeConfig,
|
|
fixture.privateKey,
|
|
}
|
|
before := snapshotDroidFiles(t, paths...)
|
|
installDroidFixture(t, fixture)
|
|
requireDroidSnapshot(t, before)
|
|
requireNoDroidStagingFiles(t, fixture.homeDir)
|
|
}
|
|
|
|
func TestDroidInstallMigratesLegacyWindowsCommands(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("Windows migration contract")
|
|
}
|
|
fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{"hooks": map[string]any{}})})
|
|
nodePath, err := resolveNodeRuntime()
|
|
if err != nil {
|
|
t.Fatalf("resolve Node.js: %v", err)
|
|
}
|
|
group := func(script string) map[string]any {
|
|
return map[string]any{
|
|
"matcher": "*",
|
|
"hooks": []any{map[string]any{
|
|
"type": "command",
|
|
"command": fmt.Sprintf(`"%s" "%s"`, nodePath, script),
|
|
"timeout": 10,
|
|
}},
|
|
}
|
|
}
|
|
writeDroidTestObject(t, fixture.configPath, map[string]any{"hooks": map[string]any{
|
|
"PreToolUse": []any{group(fixture.guardPath)},
|
|
"PostToolUse": []any{group(fixture.hookPath)},
|
|
}})
|
|
installDroidFixture(t, fixture)
|
|
hooks := droidCurrentHooks(t, fixture.configPath)
|
|
for _, event := range droidToolEvents {
|
|
groups := requireDroidArray(t, hooks[event])
|
|
if len(groups) != 1 {
|
|
t.Fatalf("%s groups = %#v", event, groups)
|
|
}
|
|
command := requireDroidObject(t, requireDroidArray(t, requireDroidObject(t, groups[0])["hooks"])[0])["command"].(string)
|
|
if !strings.HasPrefix(command, "& '") {
|
|
t.Fatalf("migrated command = %q", command)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDroidRootHookFileHasWholeSourcePrecedence(t *testing.T) {
|
|
root := `{
|
|
// active root source
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }
|
|
]
|
|
}
|
|
}
|
|
`
|
|
settings := `{
|
|
// inactive settings source
|
|
"theme": "dark",
|
|
"hooks": {
|
|
"PostToolUse": [
|
|
{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }
|
|
]
|
|
}
|
|
}
|
|
`
|
|
fixture := prepareDroidFixture(t, droidFixtureOptions{
|
|
root: droidString(root),
|
|
settings: droidString(settings),
|
|
})
|
|
installDroidFixture(t, fixture)
|
|
if !strings.Contains(readDroidTestFile(t, fixture.configPath), "active root source") {
|
|
t.Fatal("root JSONC comment was lost")
|
|
}
|
|
hooks := droidCurrentHooks(t, fixture.configPath)
|
|
pre := requireDroidArray(t, hooks["PreToolUse"])
|
|
userCommand := requireDroidObject(t, requireDroidArray(t, requireDroidObject(t, pre[0])["hooks"])[0])["command"]
|
|
if userCommand != "root-user" {
|
|
t.Fatalf("root user command = %#v", userCommand)
|
|
}
|
|
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath))
|
|
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath))
|
|
if readDroidTestFile(t, fixture.settingsPath) != settings {
|
|
t.Fatal("inactive settings source changed")
|
|
}
|
|
}
|
|
|
|
func 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{
|
|
settings: settings,
|
|
localSettings: droidJSON(map[string]any{"hooks": map[string]any{"SessionStart": []any{}}}),
|
|
})
|
|
baseBefore := readDroidTestFile(t, fixture.settingsPath)
|
|
installDroidFixture(t, fixture)
|
|
if readDroidTestFile(t, fixture.settingsPath) != baseBefore {
|
|
t.Fatal("base settings changed while local settings were active")
|
|
}
|
|
hooks := requireDroidObject(t, readDroidTestObject(t, fixture.localSettingsPath)["hooks"])
|
|
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath))
|
|
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath))
|
|
}
|
|
|
|
func TestDroidLegacyHookFileStaysActiveUntilFactoryMigratesIt(t *testing.T) {
|
|
legacy := droidJSON(map[string]any{"PreToolUse": []any{map[string]any{
|
|
"matcher": "Read",
|
|
"hooks": []any{map[string]any{"type": "command", "command": "legacy-user"}},
|
|
}}})
|
|
settings := droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}})
|
|
fixture := prepareDroidFixture(t, droidFixtureOptions{legacy: legacy, settings: settings})
|
|
settingsBefore := readDroidTestFile(t, fixture.settingsPath)
|
|
installDroidFixture(t, fixture)
|
|
requireMissingDroidFile(t, fixture.configPath)
|
|
legacyHooks := readDroidTestObject(t, fixture.legacyPath)
|
|
requireDroidNativeGroup(t, droidManagedGroup(t, legacyHooks["PreToolUse"], fixture.guardPath))
|
|
requireDroidNativeGroup(t, droidManagedGroup(t, legacyHooks["PostToolUse"], fixture.hookPath))
|
|
if readDroidTestFile(t, fixture.settingsPath) != settingsBefore {
|
|
t.Fatal("inactive settings source changed")
|
|
}
|
|
}
|
|
|
|
func TestDroidInstallCleansExactManagedHooksFromInactiveSources(t *testing.T) {
|
|
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
|
installDroidFixture(t, fixture)
|
|
writeDroidTestObject(t, fixture.settingsPath, map[string]any{
|
|
"hooks": droidCurrentHooks(t, fixture.configPath),
|
|
"owner": "user",
|
|
})
|
|
installDroidFixture(t, fixture)
|
|
settings := readDroidTestObject(t, fixture.settingsPath)
|
|
if settings["owner"] != "user" {
|
|
t.Fatalf("inactive settings owner = %#v", settings["owner"])
|
|
}
|
|
hooks := requireDroidObject(t, settings["hooks"])
|
|
if _, exists := hooks["PreToolUse"]; exists {
|
|
t.Fatalf("inactive PreToolUse remains: %#v", hooks)
|
|
}
|
|
if _, exists := hooks["PostToolUse"]; exists {
|
|
t.Fatalf("inactive PostToolUse remains: %#v", hooks)
|
|
}
|
|
}
|
|
|
|
type droidAPIRequest struct {
|
|
method string
|
|
path string
|
|
body []byte
|
|
}
|
|
|
|
func TestDroidGuardBlocksAndAuditPreservesNativePayload(t *testing.T) {
|
|
requests := make([]droidAPIRequest, 0)
|
|
var requestLock sync.Mutex
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
body, err := io.ReadAll(request.Body)
|
|
if err != nil {
|
|
t.Errorf("read API request: %v", err)
|
|
}
|
|
requestLock.Lock()
|
|
requests = append(requests, droidAPIRequest{request.Method, request.URL.Path, body})
|
|
requestLock.Unlock()
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
if request.Method == http.MethodPost {
|
|
writer.WriteHeader(http.StatusCreated)
|
|
_, _ = writer.Write([]byte(`{"operation":{"accepted":true}}`))
|
|
return
|
|
}
|
|
_, _ = writer.Write([]byte(`{"agent":{"status":"active"}}`))
|
|
}))
|
|
defer server.Close()
|
|
fixture := prepareDroidFixture(t, droidFixtureOptions{baseURL: server.URL})
|
|
installDroidFixture(t, fixture)
|
|
hooks := droidCurrentHooks(t, fixture.configPath)
|
|
guard := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)
|
|
audit := droidManagedHandler(t, hooks["PostToolUse"], fixture.hookPath)
|
|
writeDroidTestObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
|
|
"status": "frozen",
|
|
"cached_at": time.Now().UnixMilli(),
|
|
})
|
|
prePayload := map[string]any{
|
|
"session_id": "session-1",
|
|
"transcript_path": filepath.Join(fixture.homeDir, "transcript.jsonl"),
|
|
"cwd": fixture.workspaceDir,
|
|
"permission_mode": "auto-high",
|
|
"hook_event_name": "PreToolUse",
|
|
"tool_name": "Execute",
|
|
"tool_input": map[string]any{"command": "echo test"},
|
|
}
|
|
preRaw, _ := json.Marshal(prePayload)
|
|
guardResult := runDroidCommand(t, guard["command"].(string), fixture.homeDir, string(preRaw))
|
|
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, `Agent "droid" is frozen`) {
|
|
t.Fatalf("guard result = %#v", guardResult)
|
|
}
|
|
postPayload := make(map[string]any, len(prePayload)+1)
|
|
for key, value := range prePayload {
|
|
postPayload[key] = value
|
|
}
|
|
postPayload["hook_event_name"] = "PostToolUse"
|
|
postPayload["tool_response"] = map[string]any{"output": "test", "success": true}
|
|
postRaw, _ := json.Marshal(postPayload)
|
|
auditResult := runDroidCommand(t, audit["command"].(string), fixture.homeDir, string(postRaw))
|
|
if auditResult.exitCode != 0 {
|
|
t.Fatalf("audit result = %#v", auditResult)
|
|
}
|
|
requestLock.Lock()
|
|
defer requestLock.Unlock()
|
|
var operation map[string]any
|
|
for _, request := range requests {
|
|
if request.method == http.MethodPost {
|
|
if err := json.Unmarshal(request.body, &operation); err != nil {
|
|
t.Fatalf("decode audit request: %v", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if operation == nil || !reflect.DeepEqual(operation["payload"], postPayload) {
|
|
t.Fatalf("native operation = %#v", operation)
|
|
}
|
|
if !reflect.DeepEqual(operation["subject"], map[string]any{"session_id": "session-1"}) ||
|
|
!reflect.DeepEqual(operation["action"], map[string]any{"tool": "Execute"}) {
|
|
t.Fatalf("native operation identity = %#v", operation)
|
|
}
|
|
}
|
|
|
|
func TestDroidStatusRequiresExactHooksAndRuntimeSources(t *testing.T) {
|
|
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
|
installDroidFixture(t, fixture)
|
|
status, err := fixture.plugin.Status()
|
|
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
|
t.Fatalf("installed status = %#v, %v", status, err)
|
|
}
|
|
if err := os.WriteFile(fixture.hookPath, []byte("tampered\n"), 0700); err != nil {
|
|
t.Fatalf("tamper audit runtime: %v", err)
|
|
}
|
|
status, err = fixture.plugin.Status()
|
|
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
|
t.Fatalf("tampered status = %#v, %v", status, err)
|
|
}
|
|
}
|
|
|
|
func TestDroidStatusUsesOneWholeActiveSource(t *testing.T) {
|
|
fixture := prepareDroidFixture(t, droidFixtureOptions{
|
|
settings: droidJSON(map[string]any{"hooks": map[string]any{}}),
|
|
})
|
|
installDroidFixture(t, fixture)
|
|
settingsHooks := requireDroidObject(t, readDroidTestObject(t, fixture.settingsPath)["hooks"])
|
|
writeDroidTestObject(t, fixture.configPath, map[string]any{"hooks": map[string]any{
|
|
"PreToolUse": settingsHooks["PreToolUse"],
|
|
}})
|
|
status, err := fixture.plugin.Status()
|
|
if err != nil || status.HookConfigured || status.Installed {
|
|
t.Fatalf("split-source status = %#v, %v", status, err)
|
|
}
|
|
}
|
|
|
|
func TestDroidStatusAndUninstallDoNotRequireNodeResolution(t *testing.T) {
|
|
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
|
installDroidFixture(t, fixture)
|
|
t.Setenv("PATH", "")
|
|
status, err := fixture.plugin.Status()
|
|
if err != nil || !status.Installed {
|
|
t.Fatalf("status without PATH = %#v, %v", status, err)
|
|
}
|
|
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
|
|
t.Fatalf("uninstall without PATH: %v", err)
|
|
}
|
|
requireMissingDroidFile(t, fixture.configPath)
|
|
}
|