From b936443a432b51453ceedc54d78ab224fa2dc718 Mon Sep 17 00:00:00 2001 From: yyovil Date: Sat, 6 Jun 2026 05:24:10 +0530 Subject: [PATCH] feat(agents): add goose adapter Registers the goose harness, stacked on the agent platform. Includes its own activity deriver. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agent/activitydispatch/dispatch.go | 2 + .../internal/adapters/agent/goose/activity.go | 35 ++ .../adapters/agent/goose/activity_test.go | 32 ++ .../internal/adapters/agent/goose/goose.go | 326 +++++++++++++ .../adapters/agent/goose/goose_test.go | 440 ++++++++++++++++++ .../internal/adapters/agent/goose/hooks.go | 352 ++++++++++++++ .../adapters/agent/registry/registry.go | 2 + backend/internal/daemon/wiring_test.go | 1 + 8 files changed, 1190 insertions(+) create mode 100644 backend/internal/adapters/agent/goose/activity.go create mode 100644 backend/internal/adapters/agent/goose/activity_test.go create mode 100644 backend/internal/adapters/agent/goose/goose.go create mode 100644 backend/internal/adapters/agent/goose/goose_test.go create mode 100644 backend/internal/adapters/agent/goose/hooks.go diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index 89c7181..e9cf0bd 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -15,6 +15,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/goose" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/qwen" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -43,6 +44,7 @@ var Derivers = map[string]DeriveFunc{ "cursor": cursor.DeriveActivityState, "qwen": qwen.DeriveActivityState, "copilot": copilot.DeriveActivityState, + "goose": goose.DeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when diff --git a/backend/internal/adapters/agent/goose/activity.go b/backend/internal/adapters/agent/goose/activity.go new file mode 100644 index 0000000..1835689 --- /dev/null +++ b/backend/internal/adapters/agent/goose/activity.go @@ -0,0 +1,35 @@ +package goose + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Goose hook event onto an AO activity state. The +// bool is false when the event carries no activity signal. +// +// event is the AO hook sub-command name installed in gooseManagedHooks +// ("session-start", "user-prompt-submit", "stop", "permission-request"), not +// the native Goose event name. +// +// Goose's native hook surface (as of 2026-05) emits SessionStart / +// UserPromptSubmit / Stop / SessionEnd plus the tool-use events, but has no +// dedicated permission/approval event yet, so AO does not install a +// "permission-request" hook today. The case is kept here so that, if a future +// Goose release adds an approval lifecycle event, mapping it to waiting_input is +// a one-line hooks.go change with no deriver edit needed. +// +// TODO(goose): ActivityExited is still runtime-observation-owned. Goose has a +// native SessionEnd hook; if AO starts installing it, map it to ActivityExited +// here. Until then, the lifecycle reaper marks a dead Goose runtime as exited. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/goose/activity_test.go b/backend/internal/adapters/agent/goose/activity_test.go new file mode 100644 index 0000000..224ac8a --- /dev/null +++ b/backend/internal/adapters/agent/goose/activity_test.go @@ -0,0 +1,32 @@ +package goose + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + name string + event string + want domain.ActivityState + wantOK bool + }{ + {"session start -> active", "session-start", domain.ActivityActive, true}, + {"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true}, + {"stop -> idle", "stop", domain.ActivityIdle, true}, + {"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true}, + {"unknown event -> no signal", "frobnicate", "", false}, + {"empty event -> no signal", "", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := DeriveActivityState(tt.event, []byte(`{}`)) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", + tt.event, got, ok, tt.want, tt.wantOK) + } + }) + } +} diff --git a/backend/internal/adapters/agent/goose/goose.go b/backend/internal/adapters/agent/goose/goose.go new file mode 100644 index 0000000..1045102 --- /dev/null +++ b/backend/internal/adapters/agent/goose/goose.go @@ -0,0 +1,326 @@ +// Package goose implements the Goose (Block) agent adapter: launching new +// headless sessions, resuming hook-tracked sessions, installing +// workspace-local lifecycle hooks, and reading hook-derived session info. +// +// Goose (binary "goose") runs headlessly via `goose run -t ""`. It has a +// native Claude-Code-style lifecycle hook system (released 2026-05): a plugin +// directory under /.agents/plugins//hooks/hooks.json is +// auto-discovered at startup and its commands run on SessionStart / +// UserPromptSubmit / Stop / etc. AO installs its hooks there, so AO derives +// native session identity and activity from Goose hooks (Tier A), the same way +// the Codex adapter does. +// +// Permission/approval is controlled by the GOOSE_MODE environment variable +// (auto / approve / chat / smart_approve), not a CLI flag, so non-default modes +// are delivered as an `env GOOSE_MODE=` argv prefix (the same technique +// the opencode adapter uses for OPENCODE_PERMISSION). The default mode emits no +// prefix so Goose defers to the user's own config. +// +// Note: the AO repo also vendors pressly/goose as its SQLite migration tool, +// but that is a different Go import path; this package's name `goose` only +// collides at the import-alias level, which central wiring resolves. +package goose + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + adapterID = "goose" + + gooseTitleMetadataKey = "title" + gooseSummaryMetadataKey = "summary" + + // gooseModeEnvVar is the only permission-control surface Goose honors: the + // approval mode is read from this process env var, not from any CLI flag. + gooseModeEnvVar = "GOOSE_MODE" +) + +// Plugin is the Goose agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Goose adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Goose", + Description: "Run Goose worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Goose exposes none yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new headless Goose session: +// +// [env GOOSE_MODE=] goose run [--system ] [-t ] +// +// The prompt is delivered in-command via `-t`. A non-default permission mode is +// rendered as an `env GOOSE_MODE=` prefix because Goose reads its approval +// mode from the environment, not from a flag. System instructions, when present, +// are passed via `--system`. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.gooseBinary(ctx) + if err != nil { + return nil, err + } + + cmd = append(gooseModeEnvPrefix(cfg.Permissions), binary, "run") + + systemPrompt, err := systemPromptText(cfg) + if err != nil { + return nil, err + } + if systemPrompt != "" { + cmd = append(cmd, "--system", systemPrompt) + } + + if cfg.Prompt != "" { + cmd = append(cmd, "-t", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Goose receives its prompt in the launch +// command itself (via `-t`). +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Goose session: +// +// [env GOOSE_MODE=] goose run --resume --session-id +// +// ok is false when the hook-derived native session id has not landed yet, so +// callers can fall back to fresh launch behavior. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.gooseBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = append(gooseModeEnvPrefix(cfg.Permissions), binary, "run", "--resume", "--session-id", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Goose hook-derived metadata. Metadata is intentionally +// nil for Goose: callers get the normalized fields directly. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[gooseTitleMetadataKey], + Summary: session.Metadata[gooseSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// systemPromptText returns the system instructions to inject. Goose's `--system` +// flag takes inline text only (no file variant), so a system-prompt file is read +// from disk and its contents inlined. A read failure is surfaced as an error so a +// misconfigured prompt file does not silently fall back to the inline +// SystemPrompt string; only an empty-after-trim file falls back. +func systemPromptText(cfg ports.LaunchConfig) (string, error) { + if cfg.SystemPromptFile != "" { + data, err := os.ReadFile(cfg.SystemPromptFile) //nolint:gosec // path is AO-owned launch config + if err != nil { + return "", fmt.Errorf("read %s: %w", cfg.SystemPromptFile, err) + } + if text := strings.TrimSpace(string(data)); text != "" { + return text, nil + } + } + return cfg.SystemPrompt, nil +} + +// gooseModeEnvPrefix renders mode as an `env GOOSE_MODE=` argv prefix, or +// nil for the default mode. +// +// The var must reach Goose as a process env var, not an argv flag. The runtime +// runs the argv through a shell, which execs `env`, which sets the var and execs +// goose. A bare `GOOSE_MODE=...` argv element would not work: the runtime +// shell-quotes every element, and a quoted token is run as a command rather than +// read as an assignment — hence the explicit `env` wrapper. POSIX-only, which +// matches the runtime. +func gooseModeEnvPrefix(mode ports.PermissionMode) []string { + value := gooseMode(mode) + if value == "" { + return nil + } + return []string{"env", gooseModeEnvVar + "=" + value} +} + +// gooseMode maps an AO permission mode onto Goose's GOOSE_MODE value. +// +// - default → "": no env; Goose's own config decides approvals. +// - accept-edits → smart_approve: auto-approves safe edits, asks on risk. +// - auto → auto: fully autonomous, no approval prompts. +// - bypass-permissions → auto: Goose's fully-autonomous mode is the nearest +// equivalent to bypass. +func gooseMode(mode ports.PermissionMode) string { + switch normalizePermissionMode(mode) { + case ports.PermissionModeAcceptEdits: + return "smart_approve" + case ports.PermissionModeAuto: + return "auto" + case ports.PermissionModeBypassPermissions: + return "auto" + default: + return "" + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + // Empty or unrecognized: defer to Goose's own config (no env). + return ports.PermissionModeDefault + } +} + +// ResolveGooseBinary returns the path to the goose binary on this machine, +// searching PATH then a handful of well-known install locations (the install +// script's ~/.local/bin, Homebrew, Cargo, npm global). Returns "goose" as a +// last-ditch fallback so callers see a clear "command not found" rather than an +// empty argv. +func ResolveGooseBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"goose.cmd", "goose.exe", "goose"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "goose.cmd"), + filepath.Join(appData, "npm", "goose.exe"), + ) + } + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + candidates = append(candidates, filepath.Join(localAppData, "Programs", "goose", "goose.exe")) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "goose.exe")) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "goose", nil + } + + if path, err := exec.LookPath("goose"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/goose", + "/opt/homebrew/bin/goose", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "goose"), + filepath.Join(home, ".cargo", "bin", "goose"), + filepath.Join(home, ".npm", "bin", "goose"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "goose", nil +} + +func (p *Plugin) gooseBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveGooseBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/goose/goose_test.go b/backend/internal/adapters/agent/goose/goose_test.go new file mode 100644 index 0000000..4de5c94 --- /dev/null +++ b/backend/internal/adapters/agent/goose/goose_test.go @@ -0,0 +1,440 @@ +package goose + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifestIDIsGoose(t *testing.T) { + m := New().Manifest() + if m.ID != "goose" { + t.Fatalf("Manifest().ID = %q, want %q", m.ID, "goose") + } + if m.Name != "Goose" { + t.Fatalf("Manifest().Name = %q, want %q", m.Name, "Goose") + } + if len(m.Capabilities) != 1 || m.Capabilities[0] != "agent" { + t.Fatalf("Manifest().Capabilities = %#v, want [agent]", m.Capabilities) + } +} + +func TestGetLaunchCommandBuildsArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + SystemPrompt: "be terse", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "env", "GOOSE_MODE=auto", + "goose", "run", + "--system", "be terse", + "-t", "-fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandSystemPromptFileInlined(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "prompt.md") + if err := os.WriteFile(file, []byte(" from file \n"), 0o600); err != nil { + t.Fatal(err) + } + plugin := &Plugin{resolvedBinary: "goose"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPromptFile: file, + SystemPrompt: "inline fallback ignored", + Prompt: "do work", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"goose", "run", "--system", "from file", "-t", "do work"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: "env", + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: []string{"env", "GOOSE_MODE=smart_approve"}, + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: []string{"env", "GOOSE_MODE=auto"}, + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"env", "GOOSE_MODE=auto"}, + }, + { + name: "empty", + permission: "", + notExpected: "env", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + if tt.notExpected != "" && contains(cmd, tt.notExpected) { + t.Fatalf("command %#v contains %q", cmd, tt.notExpected) + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestContextCancellationIsHonored(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := plugin.GetConfigSpec(ctx); err == nil { + t.Fatal("GetConfigSpec: expected error from cancelled context") + } + if _, err := plugin.GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); err == nil { + t.Fatal("GetPromptDeliveryStrategy: expected error from cancelled context") + } + if _, _, err := plugin.GetRestoreCommand(ctx, ports.RestoreConfig{}); err == nil { + t.Fatal("GetRestoreCommand: expected error from cancelled context") + } + if _, _, err := plugin.SessionInfo(ctx, ports.SessionRef{}); err == nil { + t.Fatal("SessionInfo: expected error from cancelled context") + } + if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: "/tmp"}); err == nil { + t.Fatal("GetAgentHooks: expected error from cancelled context") + } +} + +func TestGetAgentHooksInstallsGooseHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + workspace := t.TempDir() + hooksPath := gooseHooksPath(workspace) + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"hooks":{"Stop":[{"matcher":null,"hooks":[{"type":"command","command":"custom stop hook","timeout":3}]}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + // A second install must not duplicate AO hook commands. + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var config gooseHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + if config.Hooks == nil { + t.Fatalf("hooks config missing hooks object: %#v", config) + } + for _, spec := range gooseManagedHooks { + entries := config.Hooks[spec.Event] + if count := countGooseHookCommand(entries, spec.Command); count != 1 { + t.Fatalf("%s command count = %d, want 1 in %#v", spec.Event, count, entries) + } + } + stopEntries := config.Hooks["Stop"] + if countGooseHookCommand(stopEntries, "custom stop hook") != 1 { + t.Fatalf("existing Stop hook was not preserved: %#v", stopEntries) + } +} + +func TestUninstallHooksRemovesGooseHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + workspace := t.TempDir() + hooksPath := gooseHooksPath(workspace) + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own Stop hook; it must survive uninstall. + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"hooks":{"Stop":[{"matcher":null,"hooks":[{"type":"command","command":"custom stop hook","timeout":3}]}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var config gooseHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + for _, spec := range gooseManagedHooks { + if got := countGooseHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 { + t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got) + } + } + if countGooseHookCommand(config.Hooks["Stop"], "custom stop hook") != 1 { + t.Fatalf("user Stop hook not preserved: %#v", config.Hooks["Stop"]) + } +} + +func TestGetAgentHooksRequiresWorkspacePath(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{}); err == nil { + t.Fatal("expected error when WorkspacePath is empty") + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "thread-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "env", "GOOSE_MODE=auto", + "goose", "run", "--resume", "--session-id", "thread-123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "thread-123", + gooseTitleMetadataKey: "Fix login redirect", + gooseSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "thread-123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for Goose", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func TestResolveGooseBinaryFallback(t *testing.T) { + // On a machine without goose on PATH or any well-known location, resolution + // still returns a usable last-ditch "goose" name rather than empty. + got, err := ResolveGooseBinary(context.Background()) + if err != nil { + t.Fatalf("ResolveGooseBinary err = %v", err) + } + if got == "" { + t.Fatal("ResolveGooseBinary returned empty binary") + } + if !strings.Contains(got, "goose") { + t.Fatalf("ResolveGooseBinary = %q, want a path containing goose", got) + } +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func containsSubsequence(values []string, needle []string) bool { + if len(needle) == 0 { + return true + } + + for start := range values { + if start+len(needle) > len(values) { + return false + } + ok := true + for offset, want := range needle { + if values[start+offset] != want { + ok = false + break + } + } + if ok { + return true + } + } + + return false +} + +func countGooseHookCommand(entries []gooseMatcherGroup, command string) int { + count := 0 + for _, entry := range entries { + for _, hook := range entry.Hooks { + if hook.Command == command { + count++ + } + } + } + return count +} diff --git a/backend/internal/adapters/agent/goose/hooks.go b/backend/internal/adapters/agent/goose/hooks.go new file mode 100644 index 0000000..0e128a7 --- /dev/null +++ b/backend/internal/adapters/agent/goose/hooks.go @@ -0,0 +1,352 @@ +package goose + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // goosePluginDirName is the AO plugin directory under a workspace's + // .agents/plugins/. Goose auto-discovers any plugin dir containing a + // hooks/hooks.json at startup; unlike Codex there is no separate feature + // flag to toggle, so installing the file is sufficient. + gooseHooksRootDirName = ".agents" + goosePluginsDirName = "plugins" + goosePluginName = "ao" + gooseHooksSubDirName = "hooks" + gooseHooksFileName = "hooks.json" + + // gooseHookCommandPrefix identifies the hook commands AO owns, so install + // skips duplicates and uninstall recognizes AO entries by prefix without an + // embedded template to diff against. + gooseHookCommandPrefix = "ao hooks goose " + gooseHookTimeout = 30 +) + +// gooseHookFile is the on-disk shape of .agents/plugins/ao/hooks/hooks.json. It +// is used by tests to decode the written file. +type gooseHookFile struct { + Hooks map[string][]gooseMatcherGroup `json:"hooks"` +} + +type gooseMatcherGroup struct { + Matcher *string `json:"matcher,omitempty"` + Hooks []gooseHookEntry `json:"hooks"` +} + +type gooseHookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// gooseHookSpec describes one hook AO installs, defined in code rather than read +// from an embedded hooks file. +type gooseHookSpec struct { + Event string + Command string +} + +// gooseManagedHooks is the source of truth for the hooks AO installs. Goose +// groups every hook under the nil matcher. Goose has no permission/approval +// lifecycle event yet, so AO installs only the session/prompt/stop signals. +var gooseManagedHooks = []gooseHookSpec{ + {Event: "SessionStart", Command: gooseHookCommandPrefix + "session-start"}, + {Event: "UserPromptSubmit", Command: gooseHookCommandPrefix + "user-prompt-submit"}, + {Event: "Stop", Command: gooseHookCommandPrefix + "stop"}, +} + +// GetAgentHooks installs AO's Goose hooks into the worktree-local +// .agents/plugins/ao/hooks/hooks.json file. Existing hook entries are preserved +// and duplicate AO commands are not appended. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("goose.GetAgentHooks: WorkspacePath is required") + } + + hooksPath := gooseHooksPath(cfg.WorkspacePath) + topLevel, rawHooks, err := readGooseHooks(hooksPath) + if err != nil { + return fmt.Errorf("goose.GetAgentHooks: %w", err) + } + + for event, specs := range groupGooseHooksByEvent() { + var existingGroups []gooseMatcherGroup + if err := parseGooseHookType(rawHooks, event, &existingGroups); err != nil { + return fmt.Errorf("goose.GetAgentHooks: %w", err) + } + for _, spec := range specs { + if !gooseHookCommandExists(existingGroups, spec.Command) { + entry := gooseHookEntry{Type: "command", Command: spec.Command, Timeout: gooseHookTimeout} + existingGroups = addGooseHook(existingGroups, entry) + } + } + if err := marshalGooseHookType(rawHooks, event, existingGroups); err != nil { + return fmt.Errorf("goose.GetAgentHooks: %w", err) + } + } + + if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("goose.GetAgentHooks: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Goose hooks from the workspace-local +// .agents/plugins/ao/hooks/hooks.json file, leaving user-defined hooks +// untouched. A missing file is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("goose.UninstallHooks: workspacePath is required") + } + + hooksPath := gooseHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readGooseHooks(hooksPath) + if err != nil { + return fmt.Errorf("goose.UninstallHooks: %w", err) + } + + for _, event := range gooseManagedEvents() { + var groups []gooseMatcherGroup + if err := parseGooseHookType(rawHooks, event, &groups); err != nil { + return fmt.Errorf("goose.UninstallHooks: %w", err) + } + groups = removeGooseManagedHooks(groups) + if err := marshalGooseHookType(rawHooks, event, groups); err != nil { + return fmt.Errorf("goose.UninstallHooks: %w", err) + } + } + + if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("goose.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Goose hook is present in the +// workspace-local hooks file. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("goose.AreHooksInstalled: workspacePath is required") + } + + hooksPath := gooseHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readGooseHooks(hooksPath) + if err != nil { + return false, fmt.Errorf("goose.AreHooksInstalled: %w", err) + } + + for _, event := range gooseManagedEvents() { + var groups []gooseMatcherGroup + if err := parseGooseHookType(rawHooks, event, &groups); err != nil { + return false, fmt.Errorf("goose.AreHooksInstalled: %w", err) + } + for _, group := range groups { + for _, hook := range group.Hooks { + if isGooseManagedHook(hook.Command) { + return true, nil + } + } + } + } + return false, nil +} + +func gooseHooksPath(workspacePath string) string { + return filepath.Join(workspacePath, gooseHooksRootDirName, goosePluginsDirName, goosePluginName, gooseHooksSubDirName, gooseHooksFileName) +} + +// readGooseHooks loads the hooks file into a top-level raw map plus the decoded +// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty file +// yields empty maps. +func readGooseHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeGooseHooks folds rawHooks back into topLevel and writes the file. An +// empty hooks map drops the "hooks" key entirely. +func writeGooseHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("create hook dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", hooksPath, err) + } + data = append(data, '\n') + if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", hooksPath, err) + } + return nil +} + +// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- +// write can't leave a truncated/empty file that Goose then fails to parse. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// groupGooseHooksByEvent groups the managed hook specs by their Goose event so +// each event's array is rewritten once. +func groupGooseHooksByEvent() map[string][]gooseHookSpec { + byEvent := map[string][]gooseHookSpec{} + for _, spec := range gooseManagedHooks { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +// gooseManagedEvents returns the distinct Goose events AO manages, in the order +// they first appear in gooseManagedHooks. +func gooseManagedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(gooseManagedHooks)) + for _, spec := range gooseManagedHooks { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +func isGooseManagedHook(command string) bool { + return strings.HasPrefix(command, gooseHookCommandPrefix) +} + +// removeGooseManagedHooks strips AO hook entries from every group, dropping any +// group left without hooks. +func removeGooseManagedHooks(groups []gooseMatcherGroup) []gooseMatcherGroup { + result := make([]gooseMatcherGroup, 0, len(groups)) + for _, group := range groups { + kept := make([]gooseHookEntry, 0, len(group.Hooks)) + for _, hook := range group.Hooks { + if !isGooseManagedHook(hook.Command) { + kept = append(kept, hook) + } + } + if len(kept) > 0 { + group.Hooks = kept + result = append(result, group) + } + } + return result +} + +func parseGooseHookType(rawHooks map[string]json.RawMessage, event string, target *[]gooseMatcherGroup) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalGooseHookType(rawHooks map[string]json.RawMessage, event string, groups []gooseMatcherGroup) error { + if len(groups) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func gooseHookCommandExists(groups []gooseMatcherGroup, command string) bool { + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +func addGooseHook(groups []gooseMatcherGroup, hook gooseHookEntry) []gooseMatcherGroup { + for i, group := range groups { + if group.Matcher == nil { + groups[i].Hooks = append(groups[i].Hooks, hook) + return groups + } + } + return append(groups, gooseMatcherGroup{ + Matcher: nil, + Hooks: []gooseHookEntry{hook}, + }) +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index 4eb56d7..7f13abe 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -16,6 +16,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/crush" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/goose" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/grok" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/qwen" @@ -41,6 +42,7 @@ func Constructors() []adapters.Adapter { cursor.New(), qwen.New(), copilot.New(), + goose.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index db9a67a..7cd7bd4 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -102,6 +102,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessCursor, "cursor"}, {domain.HarnessQwen, "qwen"}, {domain.HarnessCopilot, "copilot"}, + {domain.HarnessGoose, "goose"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness)