diff --git a/README.md b/README.md index e9adb764c..f51285142 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,6 @@ For detailed architecture diagrams, data flows, and load-bearing rules, see [arc | [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules | | [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules | | [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract | -| [Agent Adapter Contract](docs/agent/README.md) | Agent adapter interface and hook behavior | --- diff --git a/backend/internal/adapters/agent/agy/agy_test.go b/backend/internal/adapters/agent/agy/agy_test.go index b51205003..db6202c2e 100644 --- a/backend/internal/adapters/agent/agy/agy_test.go +++ b/backend/internal/adapters/agent/agy/agy_test.go @@ -200,3 +200,15 @@ func TestHooksLifecycle(t *testing.T) { t.Fatal("expected hooks to be uninstalled after UninstallHooks") } } + +func TestAuthStatus(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + + status, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Errorf("AuthStatus() = %v, want AgentAuthStatusAuthorized", status) + } +} diff --git a/backend/internal/adapters/agent/agy/auth.go b/backend/internal/adapters/agent/agy/auth.go new file mode 100644 index 000000000..658519a7d --- /dev/null +++ b/backend/internal/adapters/agent/agy/auth.go @@ -0,0 +1,17 @@ +package agy + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if _, err := p.ResolveBinary(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } + return ports.AgentAuthStatusAuthorized, nil +} diff --git a/backend/internal/adapters/agent/agy/install.go b/backend/internal/adapters/agent/agy/install.go new file mode 100644 index 000000000..223e7448d --- /dev/null +++ b/backend/internal/adapters/agent/agy/install.go @@ -0,0 +1,8 @@ +package agy + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.agyBinary(ctx) +} diff --git a/backend/internal/adapters/agent/aider/auth.go b/backend/internal/adapters/agent/aider/auth.go new file mode 100644 index 000000000..e06b090ca --- /dev/null +++ b/backend/internal/adapters/agent/aider/auth.go @@ -0,0 +1,131 @@ +package aider + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if _, err := p.ResolveBinary(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := aiderLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +var aiderAPIKeyEnvVars = []string{ + "AIDER_API_KEY", + "AIDER_OPENAI_API_KEY", + "AIDER_ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", +} + +func aiderLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range aiderAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + for _, path := range aiderConfigPaths() { + status, ok, err := aiderAuthStatusFromFile(path) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if ok { + return status, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func aiderConfigPaths() []string { + paths := []string{} + if cwd, err := os.Getwd(); err == nil && cwd != "" { + paths = append(paths, + filepath.Join(cwd, ".env"), + filepath.Join(cwd, ".aider.conf.yml"), + filepath.Join(cwd, ".aider.conf.yaml"), + ) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + paths = append(paths, + filepath.Join(home, ".env"), + filepath.Join(home, ".aider.conf.yml"), + filepath.Join(home, ".aider.conf.yaml"), + ) + } + return paths +} + +func aiderAuthStatusFromFile(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + text := strings.TrimSpace(string(data)) + if text == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + if fileContainsAPIKey(text) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func fileContainsAPIKey(text string) bool { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + lower := strings.ToLower(line) + if !strings.Contains(lower, "api") || !strings.Contains(lower, "key") { + continue + } + if valueAfterAssignment(line) != "" { + return true + } + } + return false +} + +func valueAfterAssignment(line string) string { + for _, sep := range []string{"=", ":"} { + before, after, ok := strings.Cut(line, sep) + if !ok || !strings.Contains(strings.ToLower(before), "key") { + continue + } + value := strings.Trim(strings.TrimSpace(after), `"'`) + if value != "" && !strings.EqualFold(value, "null") && !strings.EqualFold(value, "none") { + return value + } + } + return "" +} diff --git a/backend/internal/adapters/agent/aider/auth_test.go b/backend/internal/adapters/agent/aider/auth_test.go new file mode 100644 index 000000000..6635c7b9f --- /dev/null +++ b/backend/internal/adapters/agent/aider/auth_test.go @@ -0,0 +1,77 @@ +package aider + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAiderLocalAuthStatusAuthorizedWithProviderEnv(t *testing.T) { + clearAiderAuthEnv(t) + t.Setenv("OPENAI_API_KEY", "sk-test") + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAiderLocalAuthStatusAuthorizedWithConfigFile(t *testing.T) { + clearAiderAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.WriteFile(filepath.Join(home, ".aider.conf.yml"), []byte("openai-api-key: sk-test\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAiderLocalAuthStatusAuthorizedWithDotEnv(t *testing.T) { + clearAiderAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.WriteFile(filepath.Join(home, ".env"), []byte("ANTHROPIC_API_KEY=sk-ant-test\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAiderLocalAuthStatusUnknownWhenMissing(t *testing.T) { + clearAiderAuthEnv(t) + t.Setenv("HOME", t.TempDir()) + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func clearAiderAuthEnv(t *testing.T) { + t.Helper() + for _, name := range aiderAPIKeyEnvVars { + t.Setenv(name, "") + } +} diff --git a/backend/internal/adapters/agent/aider/install.go b/backend/internal/adapters/agent/aider/install.go new file mode 100644 index 000000000..6b86b07b1 --- /dev/null +++ b/backend/internal/adapters/agent/aider/install.go @@ -0,0 +1,8 @@ +package aider + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.aiderBinary(ctx) +} diff --git a/backend/internal/adapters/agent/amp/auth.go b/backend/internal/adapters/agent/amp/auth.go new file mode 100644 index 000000000..57e7c18a8 --- /dev/null +++ b/backend/internal/adapters/agent/amp/auth.go @@ -0,0 +1,121 @@ +package amp + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := ampLocalAuthStatus(ctx); err != nil || ok { + return status, err + } + return ampUsageAuthStatus(ctx, binary) +} + +func ampLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("AMP_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + status, ok, err := ampSettingsAuthStatus(ampSettingsPath()) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + return status, ok, nil +} + +func ampSettingsPath() string { + if path := strings.TrimSpace(os.Getenv("AMP_SETTINGS_FILE")); path != "" { + return expandHome(path) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".config", "amp", "settings.json") + } + return "" +} + +func ampSettingsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + if path == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ports.AgentAuthStatusUnknown, false, nil + } + return ports.AgentAuthStatusUnknown, false, err + } + var settings map[string]any + if err := json.Unmarshal(data, &settings); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, key := range []string{"amp.apiKey", "amp.api_key", "apiKey", "api_key"} { + if value, ok := settings[key]; ok { + if strings.TrimSpace(stringValue(value)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func ampUsageAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + out, err := authprobe.CmdRunner(probeCtx, binary, "usage", "--no-color") + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +func stringValue(value any) string { + switch v := value.(type) { + case string: + return v + default: + return "" + } +} + +func expandHome(path string) string { + if path == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + } + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + } + return path +} diff --git a/backend/internal/adapters/agent/amp/auth_test.go b/backend/internal/adapters/agent/amp/auth_test.go new file mode 100644 index 000000000..662e9ed7c --- /dev/null +++ b/backend/internal/adapters/agent/amp/auth_test.go @@ -0,0 +1,99 @@ +package amp + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + t.Setenv("AMP_API_KEY", "amp-key") + + got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAmpSettingsAuthStatusAuthorizedWithAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(path, []byte(`{"amp.apiKey":"amp-key"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := ampSettingsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAmpSettingsAuthStatusUnauthorizedWithEmptyAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(path, []byte(`{"amp.apiKey":""}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := ampSettingsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestAmpUsageAuthStatusAuthorizedOnSuccessfulUsage(t *testing.T) { + t.Setenv("AMP_API_KEY", "") + t.Setenv("AMP_SETTINGS_FILE", filepath.Join(t.TempDir(), "missing-settings.json")) + restore := mockAuthProbeRunner(t, func(ctx context.Context, name string, arg ...string) ([]byte, error) { + if name != "amp" || !reflect.DeepEqual(arg, []string{"usage", "--no-color"}) { + t.Fatalf("command = %s %#v, want amp usage --no-color", name, arg) + } + return []byte("Credits remaining: 100"), nil + }) + defer restore() + + got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAmpUsageAuthStatusUnauthorizedFromUsageOutput(t *testing.T) { + t.Setenv("AMP_API_KEY", "") + t.Setenv("AMP_SETTINGS_FILE", filepath.Join(t.TempDir(), "missing-settings.json")) + restore := mockAuthProbeRunner(t, func(ctx context.Context, name string, arg ...string) ([]byte, error) { + return []byte("login required"), errors.New("exit status 1") + }) + defer restore() + + got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusUnauthorized) + } +} + +func mockAuthProbeRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = runner + return func() { authprobe.CmdRunner = previous } +} diff --git a/backend/internal/adapters/agent/amp/install.go b/backend/internal/adapters/agent/amp/install.go new file mode 100644 index 000000000..20a45a9f5 --- /dev/null +++ b/backend/internal/adapters/agent/amp/install.go @@ -0,0 +1,8 @@ +package amp + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.ampBinary(ctx) +} diff --git a/backend/internal/adapters/agent/auggie/auth.go b/backend/internal/adapters/agent/auggie/auth.go new file mode 100644 index 000000000..29ae23e1f --- /dev/null +++ b/backend/internal/adapters/agent/auggie/auth.go @@ -0,0 +1,40 @@ +package auggie + +import ( + "context" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + return auggieAccountAuthStatus(ctx, binary) +} + +func auggieAccountAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + + out, err := authprobe.CmdRunner(probeCtx, binary, "account", "status") + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} diff --git a/backend/internal/adapters/agent/auggie/auth_test.go b/backend/internal/adapters/agent/auggie/auth_test.go new file mode 100644 index 000000000..2b204c5d7 --- /dev/null +++ b/backend/internal/adapters/agent/auggie/auth_test.go @@ -0,0 +1,54 @@ +package auggie + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusUsesAuggieAccountStatus(t *testing.T) { + restore := stubAuggieAuthRunner(t, func(_ context.Context, name string, arg ...string) ([]byte, error) { + if name != "auggie" { + t.Fatalf("binary = %q, want auggie", name) + } + if !reflect.DeepEqual(arg, []string{"account", "status"}) { + t.Fatalf("args = %#v, want [account status]", arg) + } + return []byte("Credits remaining: 42\n"), nil + }) + defer restore() + + status, err := (&Plugin{resolvedBinary: "auggie"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusUnauthorizedFromAuggieAccountStatus(t *testing.T) { + restore := stubAuggieAuthRunner(t, func(context.Context, string, ...string) ([]byte, error) { + return []byte("You are not currently logged in to Augment.\nRun 'auggie login' to authenticate first.\n"), errors.New("exit status 1") + }) + defer restore() + + status, err := (&Plugin{resolvedBinary: "auggie"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnauthorized) + } +} + +func stubAuggieAuthRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = runner + return func() { authprobe.CmdRunner = previous } +} diff --git a/backend/internal/adapters/agent/auggie/install.go b/backend/internal/adapters/agent/auggie/install.go new file mode 100644 index 000000000..2a605fdfe --- /dev/null +++ b/backend/internal/adapters/agent/auggie/install.go @@ -0,0 +1,8 @@ +package auggie + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.auggieBinary(ctx) +} diff --git a/backend/internal/adapters/agent/authprobe/authprobe.go b/backend/internal/adapters/agent/authprobe/authprobe.go new file mode 100644 index 000000000..ea15f8201 --- /dev/null +++ b/backend/internal/adapters/agent/authprobe/authprobe.go @@ -0,0 +1,134 @@ +package authprobe + +import ( + "context" + "os/exec" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// DefaultCommands are cheap local auth/status probes common across agent CLIs. +// Unsupported commands usually exit quickly with help text and are treated as +// unknown rather than unauthorized. +var DefaultCommands = [][]string{ + {"auth", "status"}, + {"login", "status"}, + {"providers", "list"}, +} + +// CmdRunner runs the command and returns the combined stdout/stderr. +// It is exposed as a package variable to allow mocking in tests. +var CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, arg...).CombinedOutput() +} + +// CLIStatus runs bounded local CLI probes and classifies their output. +func CLIStatus(ctx context.Context, binary string, commands [][]string) (ports.AgentAuthStatus, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, err + } + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + if len(commands) == 0 { + commands = DefaultCommands + } + for _, args := range commands { + status, err := commandStatus(ctx, binary, args) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status != ports.AgentAuthStatusUnknown { + return status, nil + } + } + return ports.AgentAuthStatusUnknown, nil +} + +func commandStatus(ctx context.Context, binary string, args []string) (ports.AgentAuthStatus, error) { + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := CmdRunner(probeCtx, binary, args...) + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + status := StatusFromText(string(out)) + if status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +// StatusFromText classifies common CLI auth/status output. +func StatusFromText(out string) ports.AgentAuthStatus { + text := strings.ToLower(out) + compactText := compact(text) + if hasAny(text, + "not logged in", + "not currently logged in", + "logged out", + "not authenticated", + "unauthenticated", + "authentication required", + "not authorized", + "unauthorized", + "login required", + "no credentials", + "0 credentials", + "no api key", + "no token", + `"loggedin": false`, + `"loggedin":false`, + ) || hasAny(compactText, + `"authenticated":false`, + `'authenticated':false`, + "authenticated:false", + "authenticated=false", + `"authorized":false`, + `'authorized':false`, + "authorized:false", + "authorized=false", + `"logged_in":false`, + `'logged_in':false`, + "logged_in:false", + "logged_in=false", + `"loggedin":false`, + `'loggedin':false`, + "loggedin:false", + "loggedin=false", + ) { + return ports.AgentAuthStatusUnauthorized + } + if hasAny(text, + "logged in", + "authenticated", + "authorized", + "token valid", + "api key found", + "credentials found", + `"loggedin": true`, + `"loggedin":true`, + ) { + return ports.AgentAuthStatusAuthorized + } + return ports.AgentAuthStatusUnknown +} + +func compact(text string) string { + return strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "").Replace(text) +} + +func hasAny(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, needle) { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/authprobe/authprobe_test.go b/backend/internal/adapters/agent/authprobe/authprobe_test.go new file mode 100644 index 000000000..0c8aa661c --- /dev/null +++ b/backend/internal/adapters/agent/authprobe/authprobe_test.go @@ -0,0 +1,96 @@ +package authprobe + +import ( + "context" + "errors" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCLIStatus_Mocked(t *testing.T) { + tests := []struct { + name string + mockOutput string + mockError error + wantStatus ports.AgentAuthStatus + wantError bool + }{ + { + name: "authorized status check", + mockOutput: "User is logged in and authenticated", + wantStatus: ports.AgentAuthStatusAuthorized, + }, + { + name: "unauthorized status check", + mockOutput: "You are not logged in", + wantStatus: ports.AgentAuthStatusUnauthorized, + }, + { + name: "unknown status check with exit error", + mockOutput: "command not found or invalid syntax", + mockError: errors.New("exit status 1"), + wantStatus: ports.AgentAuthStatusUnknown, + }, + { + name: "unknown status check with success but unrecognized output", + mockOutput: "some random output here", + wantStatus: ports.AgentAuthStatusUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save and restore CmdRunner + oldCmdRunner := CmdRunner + defer func() { CmdRunner = oldCmdRunner }() + + CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + return []byte(tt.mockOutput), tt.mockError + } + + status, err := CLIStatus(context.Background(), "mockbinary", nil) + if (err != nil) != tt.wantError { + t.Fatalf("unexpected error: %v", err) + } + if status != tt.wantStatus { + t.Errorf("CLIStatus() = %v, want %v", status, tt.wantStatus) + } + }) + } +} + +func TestStatusFromTextExplicitFalseKeys(t *testing.T) { + tests := []string{ + `{ "authenticated": false }`, + `{ "authorized": false }`, + `authenticated=false`, + `authorized: false`, + `{ "logged_in": false }`, + `{ "loggedIn": false }`, + } + + for _, out := range tests { + t.Run(out, func(t *testing.T) { + if got := StatusFromText(out); got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("StatusFromText(%q) = %q, want %q", out, got, ports.AgentAuthStatusUnauthorized) + } + }) + } +} + +func TestStatusFromTextExplicitTrueKeys(t *testing.T) { + tests := []string{ + `{ "authenticated": true }`, + `{ "authorized": true }`, + `{ "loggedIn": true }`, + } + + for _, out := range tests { + t.Run(out, func(t *testing.T) { + if got := StatusFromText(out); got != ports.AgentAuthStatusAuthorized { + t.Fatalf("StatusFromText(%q) = %q, want %q", out, got, ports.AgentAuthStatusAuthorized) + } + }) + } +} diff --git a/backend/internal/adapters/agent/autohand/auth.go b/backend/internal/adapters/agent/autohand/auth.go new file mode 100644 index 000000000..0471e8d31 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/auth.go @@ -0,0 +1,75 @@ +package autohand + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, err + } + return autohandConfigAuthStatus(autohandConfigPath()) +} + +func autohandConfigAuthStatus(configPath string) (ports.AgentAuthStatus, error) { + data, err := os.ReadFile(configPath) //nolint:gosec // path is the user's own Autohand config + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, nil + } + + var config map[string]json.RawMessage + if err := json.Unmarshal(data, &config); err != nil { + return ports.AgentAuthStatusUnknown, err + } + + authReady, authKnown := autohandCloudAuthReady(config) + if authReady { + return ports.AgentAuthStatusAuthorized, nil + } + if authKnown { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +func autohandCloudAuthReady(config map[string]json.RawMessage) (ready, known bool) { + authRaw, ok := config["auth"] + if !ok { + return false, false + } + var auth struct { + Token string `json:"token"` + } + if err := json.Unmarshal(authRaw, &auth); err != nil { + return false, false + } + return usableSecret(auth.Token), true +} + +func usableSecret(value string) bool { + normalized := strings.TrimSpace(value) + if normalized == "" { + return false + } + switch strings.ToLower(normalized) { + case "api key", "apikey", "your api key", "your-api-key", "your_api_key", "token", "your token", "your-token", "your_token", "changeme", "change-me", "replace-me", "replace_me": + return false + default: + return true + } +} diff --git a/backend/internal/adapters/agent/autohand/auth_test.go b/backend/internal/adapters/agent/autohand/auth_test.go new file mode 100644 index 000000000..6b0214911 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/auth_test.go @@ -0,0 +1,76 @@ +package autohand + +import ( + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAutohandConfigAuthStatusAuthorized(t *testing.T) { + path := writeAutohandAuthConfig(t, `{ + "auth": {"token": "session-token", "user": {"email": "agent@example.com"}}, + "provider": "zai", + "zai": {"apiKey": "real-provider-key", "model": "glm-5.1"} +}`) + + got, err := autohandConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAutohandConfigAuthStatusUnauthorizedWithMissingCloudToken(t *testing.T) { + path := writeAutohandAuthConfig(t, `{ + "auth": {"token": ""}, + "provider": "zai", + "zai": {"apiKey": "real-provider-key"} +}`) + + got, err := autohandConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusUnauthorized) + } +} + +func TestAutohandConfigAuthStatusAuthorizedWithPlaceholderProviderKey(t *testing.T) { + path := writeAutohandAuthConfig(t, `{ + "auth": {"token": "session-token"}, + "provider": "zai", + "zai": {"apiKey": "api key ", "model": "glm-5.1"} +}`) + + got, err := autohandConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAutohandConfigAuthStatusUnknownWhenMissing(t *testing.T) { + got, err := autohandConfigAuthStatus(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnknown { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusUnknown) + } +} + +func writeAutohandAuthConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/backend/internal/adapters/agent/autohand/install.go b/backend/internal/adapters/agent/autohand/install.go new file mode 100644 index 000000000..62f06f0e3 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/install.go @@ -0,0 +1,8 @@ +package autohand + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.autohandBinary(ctx) +} diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index 6e2b63362..147fec382 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -17,6 +17,7 @@ package claudecode import ( + "bytes" "context" "encoding/json" "fmt" @@ -26,6 +27,7 @@ import ( "runtime" "strings" "sync" + "time" "github.com/google/uuid" @@ -60,6 +62,7 @@ func New() *Plugin { var _ adapters.Adapter = (*Plugin)(nil) var _ ports.Agent = (*Plugin)(nil) +var _ ports.AgentAuthChecker = (*Plugin)(nil) // Manifest returns the adapter's static self-description. func (p *Plugin) Manifest() adapters.Manifest { @@ -269,6 +272,110 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks Claude Code's local authentication state without starting a +// session. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.claudeBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := claudeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "auth", "status").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + if status, ok := claudeAuthStatusFromOutput(out); ok { + return status, nil + } + if err != nil { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +func claudeAuthStatusFromOutput(out []byte) (ports.AgentAuthStatus, bool) { + start := bytes.IndexByte(out, '{') + end := bytes.LastIndexByte(out, '}') + if start < 0 || end < start { + return ports.AgentAuthStatusUnknown, false + } + var status struct { + LoggedIn bool `json:"loggedIn"` + } + if json.Unmarshal(out[start:end+1], &status) != nil { + return ports.AgentAuthStatusUnknown, false + } + if status.LoggedIn { + return ports.AgentAuthStatusAuthorized, true + } + return ports.AgentAuthStatusUnauthorized, true +} + +func claudeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + cfgPath, err := claudeConfigPath() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + return claudeConfigAuthStatus(cfgPath) +} + +func claudeConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + var root map[string]json.RawMessage + if err := json.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + var hasSubscription bool + if raw := root["hasAvailableSubscription"]; len(raw) > 0 { + _ = json.Unmarshal(raw, &hasSubscription) + } + var userID string + if raw := root["userID"]; len(raw) > 0 { + _ = json.Unmarshal(raw, &userID) + } + if strings.TrimSpace(userID) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + var oauthAccount map[string]any + if raw := root["oauthAccount"]; len(raw) > 0 { + if err := json.Unmarshal(raw, &oauthAccount); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + } + if len(oauthAccount) == 0 { + return ports.AgentAuthStatusUnknown, false, nil + } + if hasSubscription { + return ports.AgentAuthStatusAuthorized, true, nil + } + if accountUUID, ok := oauthAccount["accountUuid"].(string); ok && strings.TrimSpace(accountUUID) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + // claudeSessionUUID maps an AO session id onto a stable Claude Code // session UUID via UUIDv5 over a fixed namespace, so the same AO session // always resolves to the same Claude session. diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index 97a1d175d..eb2fc98ba 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -490,6 +490,97 @@ func TestManifestID(t *testing.T) { } } +func TestClaudeConfigAuthStatusAuthorizedWithOAuthSubscription(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{ + "hasAvailableSubscription": true, + "oauthAccount": { + "accountUuid": "account-1", + "subscriptionCreatedAt": "2026-01-01T00:00:00Z" + } + }` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusAuthorizedWithOAuthAccount(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{"oauthAccount":{"accountUuid":"account-1"}}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusAuthorizedWithUserID(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + if err := os.WriteFile(path, []byte(`{"userID":"user-1"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusUnknownWithoutOAuthIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{"oauthAccount":{}}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func TestClaudeAuthStatusFromOutputAuthorizedWithCleanJSON(t *testing.T) { + status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":true,"authMethod":"oauth_token"}`)) + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeAuthStatusFromOutputAuthorizedWithPrefixedWarning(t *testing.T) { + output := []byte("warning: ignored config line\n{\"loggedIn\":true,\"authMethod\":\"oauth_token\"}\n") + status, ok := claudeAuthStatusFromOutput(output) + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeAuthStatusFromOutputUnauthorized(t *testing.T) { + status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":false}`)) + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + func TestEnsureWorkspaceTrustedCreatesEntry(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, ".claude.json") diff --git a/backend/internal/adapters/agent/claudecode/install.go b/backend/internal/adapters/agent/claudecode/install.go new file mode 100644 index 000000000..a809f0c02 --- /dev/null +++ b/backend/internal/adapters/agent/claudecode/install.go @@ -0,0 +1,8 @@ +package claudecode + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.claudeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/cline/auth.go b/backend/internal/adapters/agent/cline/auth.go new file mode 100644 index 000000000..850d86e65 --- /dev/null +++ b/backend/internal/adapters/agent/cline/auth.go @@ -0,0 +1,122 @@ +package cline + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := clineProviderAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +type clineProvidersFile struct { + LastUsedProvider string `json:"lastUsedProvider"` + Providers map[string]clineProvider `json:"providers"` +} + +type clineProvider struct { + Settings clineProviderSettings `json:"settings"` +} + +type clineProviderSettings struct { + APIKey string `json:"apiKey"` + APIKeyAlt string `json:"apikey"` + Auth *clineProviderAuth `json:"auth"` + Provider string `json:"provider"` +} + +type clineProviderAuth struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` +} + +func clineProviderAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + path := filepath.Join(home, ".cline", "data", "settings", "providers.json") + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var file clineProvidersFile + if err := json.Unmarshal(data, &file); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(file.Providers) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + if provider, ok := configuredClineProvider(file); ok { + if providerAuthorized(provider.Settings) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func configuredClineProvider(file clineProvidersFile) (clineProvider, bool) { + if file.LastUsedProvider != "" { + if provider, ok := file.Providers[file.LastUsedProvider]; ok { + return provider, true + } + } + for _, provider := range file.Providers { + return provider, true + } + return clineProvider{}, false +} + +func providerAuthorized(settings clineProviderSettings) bool { + if strings.TrimSpace(settings.APIKey) != "" || strings.TrimSpace(settings.APIKeyAlt) != "" { + return true + } + if settings.Auth == nil { + return false + } + if strings.TrimSpace(settings.Auth.RefreshToken) != "" { + return true + } + if strings.TrimSpace(settings.Auth.AccessToken) == "" { + return false + } + if settings.Auth.ExpiresAt > 0 && settings.Auth.ExpiresAt <= time.Now().UnixMilli() { + return false + } + return true +} diff --git a/backend/internal/adapters/agent/cline/auth_test.go b/backend/internal/adapters/agent/cline/auth_test.go new file mode 100644 index 000000000..4bfbed8f9 --- /dev/null +++ b/backend/internal/adapters/agent/cline/auth_test.go @@ -0,0 +1,118 @@ +package cline + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestClineProviderAuthStatusAuthorizedWithOAuth(t *testing.T) { + writeClineProvidersFile(t, `{ + "version": 1, + "lastUsedProvider": "cline", + "providers": { + "cline": { + "settings": { + "provider": "cline", + "auth": { + "accessToken": "token", + "refreshToken": "refresh", + "expiresAt": `+futureMillis(t)+` + } + } + } + } + }`) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClineProviderAuthStatusAuthorizedWithAPIKey(t *testing.T) { + writeClineProvidersFile(t, `{ + "version": 1, + "lastUsedProvider": "openai", + "providers": { + "openai": { + "settings": { + "provider": "openai", + "apiKey": "sk-test" + } + } + } + }`) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClineProviderAuthStatusUnauthorizedWithExpiredOAuth(t *testing.T) { + writeClineProvidersFile(t, `{ + "version": 1, + "lastUsedProvider": "cline", + "providers": { + "cline": { + "settings": { + "provider": "cline", + "auth": { + "accessToken": "token", + "expiresAt": 1 + } + } + } + } + }`) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestClineProviderAuthStatusUnknownWhenMissing(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func writeClineProvidersFile(t *testing.T, content string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + settingsDir := filepath.Join(home, ".cline", "data", "settings") + if err := os.MkdirAll(settingsDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(settingsDir, "providers.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func futureMillis(t *testing.T) string { + t.Helper() + return strconv.FormatInt(time.Now().Add(time.Hour).UnixMilli(), 10) +} diff --git a/backend/internal/adapters/agent/cline/cline.go b/backend/internal/adapters/agent/cline/cline.go index 512beeb65..9c032f3c0 100644 --- a/backend/internal/adapters/agent/cline/cline.go +++ b/backend/internal/adapters/agent/cline/cline.go @@ -3,9 +3,11 @@ // workspace-local Cline hooks, and reading hook-derived session info. // // Cline is an autonomous coding agent that runs in the terminal (binary -// "cline", installed via `npm i -g cline`). AO drives it headlessly by passing -// the prompt as a positional argument and requesting NDJSON output with -// `--json`, which Cline emits one event per line for machine parsing. +// "cline", installed via `npm i -g cline`). AO drives task launches headlessly +// by passing the prompt as a positional argument and requesting NDJSON output +// with `--json`, which Cline emits one event per line for machine parsing. +// Promptless launches (notably orchestrators) must stay in Cline's interactive +// mode because Cline rejects `--json` without a prompt or piped stdin. // // AO-managed sessions derive native session identity from Cline hooks // (the workspace-local `.clinerules/hooks/` executable scripts AO installs) @@ -67,18 +69,21 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { return ports.ConfigSpec{}, nil } -// GetLaunchCommand builds the argv to start a new headless Cline session, -// requesting machine-readable NDJSON output (`--json`), applying the approval -// flags, an optional system-prompt override (`-s`), and the initial prompt as -// the trailing positional argument. The prompt is placed after `--` so a -// leading "-" is not read as a flag. +// GetLaunchCommand builds the argv to start a new Cline session. Prompted +// launches request machine-readable NDJSON output (`--json`). Promptless +// launches stay interactive because Cline's JSON output mode requires a prompt +// argument or piped stdin. The prompt is placed after `--` so a leading "-" is +// not read as a flag. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.clineBinary(ctx) if err != nil { return nil, err } - cmd = []string{binary, "--json"} + cmd = []string{binary} + if cfg.Prompt != "" { + cmd = append(cmd, "--json") + } appendApprovalFlags(&cmd, cfg.Permissions) if cfg.SystemPrompt != "" { @@ -102,9 +107,10 @@ func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.Launch } // GetRestoreCommand rebuilds the argv that continues an existing Cline session: -// `cline --json [approval flags] --id `. ok is false when the -// hook-derived native session id has not landed yet, so callers can fall back -// to fresh launch behavior. +// `cline [approval flags] --id `. Resumes are interactive +// because no prompt is supplied here. 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 @@ -120,7 +126,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) } cmd = make([]string, 0, 8) - cmd = append(cmd, binary, "--json") + cmd = append(cmd, binary) appendApprovalFlags(&cmd, cfg.Permissions) cmd = append(cmd, "--id", agentSessionID) return cmd, true, nil diff --git a/backend/internal/adapters/agent/cline/cline_test.go b/backend/internal/adapters/agent/cline/cline_test.go index 7b33121f4..2ef2c6b7c 100644 --- a/backend/internal/adapters/agent/cline/cline_test.go +++ b/backend/internal/adapters/agent/cline/cline_test.go @@ -36,6 +36,30 @@ func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) { } } +func TestGetLaunchCommandOmitsJSONForPromptlessInteractiveLaunch(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + SystemPrompt: "coordinate the project", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "cline", + "--yolo", + "-s", "coordinate the project", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } + if contains(cmd, "--json") { + t.Fatalf("promptless Cline launch must not use --json: %#v", cmd) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string @@ -254,7 +278,6 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { } want := []string{ "cline", - "--json", "--auto-approve", "true", "--id", "session-123", } diff --git a/backend/internal/adapters/agent/cline/install.go b/backend/internal/adapters/agent/cline/install.go new file mode 100644 index 000000000..d3dd2267a --- /dev/null +++ b/backend/internal/adapters/agent/cline/install.go @@ -0,0 +1,8 @@ +package cline + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.clineBinary(ctx) +} diff --git a/backend/internal/adapters/agent/codex/codex.go b/backend/internal/adapters/agent/codex/codex.go index 43d1f6b04..8ce77fbd3 100644 --- a/backend/internal/adapters/agent/codex/codex.go +++ b/backend/internal/adapters/agent/codex/codex.go @@ -13,8 +13,10 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -34,6 +36,7 @@ func New() *Plugin { var _ adapters.Adapter = (*Plugin)(nil) var _ ports.Agent = (*Plugin)(nil) +var _ ports.AgentAuthChecker = (*Plugin)(nil) // Manifest returns the adapter's static self-description. func (p *Plugin) Manifest() adapters.Manifest { @@ -146,10 +149,37 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks Codex's local login state without making a model call. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.codexBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "login", "status").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + text := strings.ToLower(string(out)) + if strings.Contains(text, "not logged in") || strings.Contains(text, "logged out") { + return ports.AgentAuthStatusUnauthorized, nil + } + if strings.Contains(text, "logged in") { + return ports.AgentAuthStatusAuthorized, nil + } + if err != nil { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + // ResolveCodexBinary returns the path to the codex binary on this machine, // searching PATH then a handful of well-known install locations -// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback -// so callers see a clear "command not found" rather than an empty argv. +// (Homebrew, Cargo, npm global, NVM). Returns "codex" as a last-ditch +// fallback so callers see a clear "command not found" rather than an empty +// argv. func ResolveCodexBinary(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -203,6 +233,7 @@ func ResolveCodexBinary(ctx context.Context) (string, error) { filepath.Join(home, ".cargo", "bin", "codex"), filepath.Join(home, ".npm", "bin", "codex"), ) + candidates = append(candidates, nvmNodeBinCandidates(home, "codex")...) } for _, candidate := range candidates { @@ -217,6 +248,14 @@ func ResolveCodexBinary(ctx context.Context) (string, error) { return "", fmt.Errorf("codex: %w", ports.ErrAgentBinaryNotFound) } +func nvmNodeBinCandidates(home, binary string) []string { + matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin", binary)) + if err != nil || len(matches) == 0 { + return nil + } + sort.Sort(sort.Reverse(sort.StringSlice(matches))) + return matches +} func resolveNativeWindowsCodex(path string) string { if runtime.GOOS != "windows" || !strings.EqualFold(filepath.Ext(path), ".cmd") { return path @@ -328,7 +367,7 @@ func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { } } -func fileExists(path string) bool { +var fileExists = func(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() } diff --git a/backend/internal/adapters/agent/codex/codex_test.go b/backend/internal/adapters/agent/codex/codex_test.go index 0981b2512..e4348f62f 100644 --- a/backend/internal/adapters/agent/codex/codex_test.go +++ b/backend/internal/adapters/agent/codex/codex_test.go @@ -90,6 +90,38 @@ func TestGetLaunchCommandWithoutWorkspaceOmitsTrustFlag(t *testing.T) { } } +func TestResolveCodexBinaryFindsNVMInstallWhenPathIsSparse(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("NVM install discovery is Unix-specific") + } + home := t.TempDir() + binDir := filepath.Join(home, ".nvm", "versions", "node", "v20.19.4", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + want := filepath.Join(binDir, "codex") + if err := os.WriteFile(want, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("PATH", "") + origFileExists := fileExists + fileExists = func(path string) bool { + return strings.HasPrefix(path, home+string(os.PathSeparator)) && origFileExists(path) + } + t.Cleanup(func() { + fileExists = origFileExists + }) + + got, err := ResolveCodexBinary(context.Background()) + if err != nil { + t.Fatalf("ResolveCodexBinary: %v", err) + } + if got != want { + t.Fatalf("ResolveCodexBinary = %q, want %q", got, want) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/adapters/agent/codex/install.go b/backend/internal/adapters/agent/codex/install.go new file mode 100644 index 000000000..94465ab41 --- /dev/null +++ b/backend/internal/adapters/agent/codex/install.go @@ -0,0 +1,8 @@ +package codex + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.codexBinary(ctx) +} diff --git a/backend/internal/adapters/agent/continueagent/auth.go b/backend/internal/adapters/agent/continueagent/auth.go new file mode 100644 index 000000000..e80dc538d --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/auth.go @@ -0,0 +1,79 @@ +package continueagent + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + + yaml "gopkg.in/yaml.v3" +) + +func continueLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("CONTINUE_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + status, ok, err := continueConfigAuthStatus(filepath.Join(home, ".continue", "config.yaml")) + if err != nil || ok { + return status, ok, err + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func continueConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if continueConfigHasCredential(&root) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func continueConfigHasCredential(node *yaml.Node) bool { + if node == nil { + return false + } + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + if continueConfigHasCredential(child) { + return true + } + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := strings.ToLower(strings.TrimSpace(node.Content[i].Value)) + value := strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`) + if (strings.Contains(key, "apikey") || strings.Contains(key, "api_key") || strings.Contains(key, "token")) && + value != "" && + !strings.EqualFold(value, "null") && + !strings.EqualFold(value, "none") { + return true + } + if continueConfigHasCredential(node.Content[i+1]) { + return true + } + } + } + return false +} diff --git a/backend/internal/adapters/agent/continueagent/auth_test.go b/backend/internal/adapters/agent/continueagent/auth_test.go new file mode 100644 index 000000000..6dfe5597e --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/auth_test.go @@ -0,0 +1,50 @@ +package continueagent + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestContinueLocalAuthStatusAuthorizedFromEnv(t *testing.T) { + t.Setenv("CONTINUE_API_KEY", "continue-key") + + status, ok, err := continueLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestContinueLocalAuthStatusUnknownWithoutEnv(t *testing.T) { + t.Setenv("CONTINUE_API_KEY", "") + t.Setenv("HOME", t.TempDir()) + + status, ok, err := continueLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func TestContinueConfigAuthStatusAuthorizedWithAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("models:\n - provider: anthropic\n apiKey: continue-key\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := continueConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} diff --git a/backend/internal/adapters/agent/continueagent/continueagent_test.go b/backend/internal/adapters/agent/continueagent/continueagent_test.go index 1cb146578..b3a23e097 100644 --- a/backend/internal/adapters/agent/continueagent/continueagent_test.go +++ b/backend/internal/adapters/agent/continueagent/continueagent_test.go @@ -29,6 +29,12 @@ func TestManifest(t *testing.T) { } } +func TestDoesNotImplementAuthChecker(t *testing.T) { + if _, ok := any(&Plugin{}).(ports.AgentAuthChecker); ok { + t.Fatal("Continue must not implement AgentAuthChecker; catalog refresh must not run model-call auth probes") + } +} + func TestGetConfigSpecEmpty(t *testing.T) { spec, err := (&Plugin{}).GetConfigSpec(context.Background()) if err != nil { diff --git a/backend/internal/adapters/agent/continueagent/install.go b/backend/internal/adapters/agent/continueagent/install.go new file mode 100644 index 000000000..d9fe23a22 --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/install.go @@ -0,0 +1,8 @@ +package continueagent + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.continueBinary(ctx) +} diff --git a/backend/internal/adapters/agent/copilot/auth.go b/backend/internal/adapters/agent/copilot/auth.go new file mode 100644 index 000000000..6444bbaad --- /dev/null +++ b/backend/internal/adapters/agent/copilot/auth.go @@ -0,0 +1,160 @@ +package copilot + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := copilotLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var copilotTokenEnvVars = []string{ + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", +} + +func copilotLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range copilotTokenEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + configStatus, configOK, err := copilotConfigAuthStatus(filepath.Join(home, ".copilot", "config.json")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if configOK { + return configStatus, true, nil + } + if status, ok, err := copilotSessionStateAuthStatus(ctx, filepath.Join(home, ".copilot", "session-state")); err != nil || ok { + return status, ok, err + } + if status, ok, err := copilotGHAuthStatus(ctx); err != nil || ok { + return status, ok, err + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func copilotConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + text := strings.TrimSpace(string(data)) + if text == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + if textContainsTokenAssignment(text) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func copilotSessionStateAuthStatus(ctx context.Context, dir string) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + paths, err := filepath.Glob(filepath.Join(dir, "*", "events.jsonl")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, path := range paths { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if copilotEventsShowModelUse(string(data)) { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func copilotEventsShowModelUse(text string) bool { + return strings.Contains(text, `"model":`) || + strings.Contains(text, `"type":"tool.execution_complete"`) || + strings.Contains(text, `"type":"message"`) +} + +func copilotGHAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, "gh", "auth", "token").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, false, probeCtx.Err() + } + text := strings.TrimSpace(string(out)) + if err == nil && text != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + if strings.Contains(strings.ToLower(text), "no oauth token") || strings.Contains(strings.ToLower(text), "not logged") { + return ports.AgentAuthStatusUnknown, false, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func textContainsTokenAssignment(text string) bool { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") { + continue + } + lower := strings.ToLower(line) + if !strings.Contains(lower, "token") && !strings.Contains(lower, "auth") { + continue + } + for _, sep := range []string{":", "="} { + _, after, ok := strings.Cut(line, sep) + if !ok { + continue + } + value := strings.Trim(strings.TrimSpace(strings.TrimRight(after, ",")), `"'`) + if value != "" && !strings.EqualFold(value, "null") && !strings.EqualFold(value, "none") { + return true + } + } + } + return false +} diff --git a/backend/internal/adapters/agent/copilot/copilot.go b/backend/internal/adapters/agent/copilot/copilot.go index 5a5d9ee45..3a38de564 100644 --- a/backend/internal/adapters/agent/copilot/copilot.go +++ b/backend/internal/adapters/agent/copilot/copilot.go @@ -6,9 +6,9 @@ // "copilot", installed via npm "@github/copilot"), NOT the older `gh copilot` // suggest/explain extension. // -// Launch runs the CLI in non-interactive ("programmatic") mode with `-p -// ` so it executes the task and exits. Permission modes map onto the -// CLI's allow flags (`--allow-tool`, `--allow-all-tools`, `--allow-all`). +// Launch runs the CLI in interactive mode so AO can keep a durable terminal +// pane attached to the session. Permission modes map onto the CLI's allow flags +// (`--allow-tool`, `--allow-all-tools`, `--allow-all`). // Restore continues an existing session via `--resume `; the // native session id (a UUID under ~/.copilot/session-state/) is captured by the // SessionStart hook AO installs (see hooks.go). @@ -74,13 +74,14 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { return ports.ConfigSpec{}, nil } -// GetLaunchCommand builds the argv to start a new headless Copilot session: +// GetLaunchCommand builds the argv to start a new interactive Copilot session: // -// copilot [permission flags] [-p ] +// copilot [permission flags] // -// The prompt is delivered with `-p`, which runs the prompt in non-interactive -// mode and exits when done. Copilot CLI does not have a documented -// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored. +// The prompt is delivered after the process starts; using `-p` runs Copilot in +// programmatic mode and exits when done, which leaves AO's terminal pane blank +// or dead. Copilot CLI does not have a documented system-prompt-injection flag, +// so SystemPrompt/SystemPromptFile are ignored. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.copilotBinary(ctx) if err != nil { @@ -90,20 +91,16 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( cmd = []string{binary} appendApprovalFlags(&cmd, cfg.Permissions) - if cfg.Prompt != "" { - cmd = append(cmd, "-p", cfg.Prompt) - } - return cmd, nil } -// GetPromptDeliveryStrategy reports that Copilot receives its prompt in the -// launch command itself (via `-p`). +// GetPromptDeliveryStrategy reports that Copilot receives its prompt after the +// interactive process starts. 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 + return ports.PromptDeliveryAfterStart, nil } // GetRestoreCommand rebuilds the argv that continues an existing Copilot @@ -194,6 +191,9 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { } if path, err := exec.LookPath("copilot"); err == nil && path != "" { + if native := copilotNativeBinaryForLoader(path); native != "" { + return native, nil + } return path, nil } @@ -206,11 +206,15 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { filepath.Join(home, ".copilot", "bin", "copilot"), filepath.Join(home, ".npm", "bin", "copilot"), filepath.Join(home, ".local", "bin", "copilot"), + filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage", "github.copilot-chat", "copilotCli", "copilot"), ) } for _, candidate := range candidates { if fileExists(candidate) { + if native := copilotNativeBinaryForLoader(candidate); native != "" { + return native, nil + } return candidate, nil } if err := ctx.Err(); err != nil { @@ -221,6 +225,25 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { return "", fmt.Errorf("copilot: %w", ports.ErrAgentBinaryNotFound) } +func copilotNativeBinaryForLoader(path string) string { + if path == "" || runtime.GOOS == "windows" { + return "" + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil || filepath.Base(resolved) != "npm-loader.js" { + return "" + } + platform := runtime.GOOS + if platform == "darwin" { + platform = "darwin" + } + native := filepath.Join(filepath.Dir(resolved), "node_modules", ".bin", "copilot-"+platform+"-"+runtime.GOARCH) + if fileExists(native) { + return native + } + return "" +} + func (p *Plugin) copilotBinary(ctx context.Context) (string, error) { p.binaryMu.Lock() defer p.binaryMu.Unlock() diff --git a/backend/internal/adapters/agent/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go index d4c4c0c1c..a81b8b520 100644 --- a/backend/internal/adapters/agent/copilot/copilot_test.go +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "testing" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -33,7 +34,7 @@ func TestGetLaunchCommandBuildsArgv(t *testing.T) { t.Fatal(err) } - want := []string{"copilot", "--allow-all", "-p", "-fix this"} + want := []string{"copilot", "--allow-all"} if !reflect.DeepEqual(cmd, want) { t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) } @@ -123,7 +124,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { if err != nil { t.Fatal(err) } - if got != ports.PromptDeliveryInCommand { + if got != ports.PromptDeliveryAfterStart { t.Fatalf("unexpected strategy: %q", got) } } @@ -140,6 +141,115 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { } } +func TestCopilotNativeBinaryForNpmLoader(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("npm loader native binary naming is covered on Unix-like platforms") + } + dir := t.TempDir() + packageDir := filepath.Join(dir, "lib", "node_modules", "@github", "copilot") + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(filepath.Join(packageDir, "node_modules", ".bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + loader := filepath.Join(packageDir, "npm-loader.js") + if err := os.WriteFile(loader, []byte("#!/usr/bin/env node\n"), 0o755); err != nil { + t.Fatal(err) + } + native := filepath.Join(packageDir, "node_modules", ".bin", "copilot-"+runtime.GOOS+"-"+runtime.GOARCH) + if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(binDir, "copilot") + if err := os.Symlink(loader, link); err != nil { + t.Fatal(err) + } + + want, err := filepath.EvalSymlinks(native) + if err != nil { + t.Fatal(err) + } + got, err := filepath.EvalSymlinks(copilotNativeBinaryForLoader(link)) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("native binary = %q, want %q", got, want) + } +} + +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + clearCopilotAuthEnv(t) + t.Setenv("GH_TOKEN", "github_pat_test") + plugin := &Plugin{resolvedBinary: "copilot"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestCopilotConfigAuthStatusAuthorizedWithPlainTextToken(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(`{"authToken":"token"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotConfigAuthStatus(configPath) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestCopilotConfigAuthStatusUnauthorizedWithEmptyConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotConfigAuthStatus(configPath) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestCopilotSessionStateAuthStatusAuthorizedWithModelEvent(t *testing.T) { + dir := t.TempDir() + sessionDir := filepath.Join(dir, "session-1") + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, "events.jsonl"), []byte(`{"type":"tool.execution_complete","data":{"model":"claude-sonnet-4.5"}}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotSessionStateAuthStatus(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func clearCopilotAuthEnv(t *testing.T) { + t.Helper() + for _, name := range copilotTokenEnvVars { + t.Setenv(name, "") + } +} + func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { plugin := &Plugin{resolvedBinary: "copilot"} diff --git a/backend/internal/adapters/agent/copilot/install.go b/backend/internal/adapters/agent/copilot/install.go new file mode 100644 index 000000000..67b5e10e4 --- /dev/null +++ b/backend/internal/adapters/agent/copilot/install.go @@ -0,0 +1,8 @@ +package copilot + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.copilotBinary(ctx) +} diff --git a/backend/internal/adapters/agent/crush/auth.go b/backend/internal/adapters/agent/crush/auth.go new file mode 100644 index 000000000..15e58ab6e --- /dev/null +++ b/backend/internal/adapters/agent/crush/auth.go @@ -0,0 +1,84 @@ +package crush + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := crushLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func crushLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + dataDir, ok := crushDataDir() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + return crushProvidersAuthStatus(filepath.Join(dataDir, "providers.json")) +} + +func crushDataDir() (string, bool) { + if dataDir := strings.TrimSpace(os.Getenv("CRUSH_DATA_DIR")); dataDir != "" { + return dataDir, true + } + if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" { + return filepath.Join(dataHome, "crush"), true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".local", "share", "crush"), true +} + +type crushProviderAuth struct { + APIKey string `json:"api_key"` +} + +func crushProvidersAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var providers []crushProviderAuth + if err := json.Unmarshal(data, &providers); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(providers) == 0 { + return ports.AgentAuthStatusUnknown, false, nil + } + for _, provider := range providers { + if strings.TrimSpace(provider.APIKey) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/crush/auth_test.go b/backend/internal/adapters/agent/crush/auth_test.go new file mode 100644 index 000000000..f47f5230b --- /dev/null +++ b/backend/internal/adapters/agent/crush/auth_test.go @@ -0,0 +1,42 @@ +package crush + +import ( + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCrushProvidersAuthStatusAuthorizedWithAPIKey(t *testing.T) { + path := writeCrushProviders(t, `[{"id":"anthropic","api_key":"sk-test"}]`) + + status, ok, err := crushProvidersAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestCrushProvidersAuthStatusUnauthorizedWithEmptyAPIKeys(t *testing.T) { + path := writeCrushProviders(t, `[{"id":"anthropic","api_key":""}]`) + + status, ok, err := crushProvidersAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func writeCrushProviders(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "providers.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/backend/internal/adapters/agent/crush/install.go b/backend/internal/adapters/agent/crush/install.go new file mode 100644 index 000000000..e6d4f041b --- /dev/null +++ b/backend/internal/adapters/agent/crush/install.go @@ -0,0 +1,8 @@ +package crush + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.crushBinary(ctx) +} diff --git a/backend/internal/adapters/agent/cursor/auth.go b/backend/internal/adapters/agent/cursor/auth.go new file mode 100644 index 000000000..487d9ad0a --- /dev/null +++ b/backend/internal/adapters/agent/cursor/auth.go @@ -0,0 +1,113 @@ +package cursor + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, err := cursorCLIAuthStatus(ctx, binary); err == nil && status != ports.AgentAuthStatusUnknown { + return status, nil + } else if err != nil && ctx.Err() != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := cursorLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func cursorCLIAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + return authprobe.CLIStatus(ctx, binary, [][]string{{"status"}}) +} + +func cursorLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return cursorConfigAuthStatus(filepath.Join(home, ".cursor", "cli-config.json")) +} + +func cursorConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + type cursorConfig struct { + AuthInfo struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + UserID any `json:"userId"` + AuthID string `json:"authId"` + } `json:"authInfo"` + } + + var cfgs []cursorConfig + if err := json.Unmarshal(data, &cfgs); err != nil { + var cfg cursorConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + cfgs = []cursorConfig{cfg} + } + for _, cfg := range cfgs { + if cursorConfigHasIdentity(cfg) { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func cursorConfigHasIdentity(cfg struct { + AuthInfo struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + UserID any `json:"userId"` + AuthID string `json:"authId"` + } `json:"authInfo"` +}) bool { + if cfg.AuthInfo.UserID != nil { + switch v := cfg.AuthInfo.UserID.(type) { + case string: + if strings.TrimSpace(v) != "" { + return true + } + default: + return true + } + } + if strings.TrimSpace(cfg.AuthInfo.AuthID) != "" || + strings.TrimSpace(cfg.AuthInfo.Email) != "" || + strings.TrimSpace(cfg.AuthInfo.DisplayName) != "" { + return true + } + return false +} diff --git a/backend/internal/adapters/agent/cursor/auth_test.go b/backend/internal/adapters/agent/cursor/auth_test.go new file mode 100644 index 000000000..5e1539e76 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/auth_test.go @@ -0,0 +1,96 @@ +package cursor + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCursorCLIAuthStatusAuthorizedFromStatus(t *testing.T) { + restore := stubCursorAuthCommand(t, []string{"status"}, []byte("✓ Logged in as user@example.com\n"), nil) + defer restore() + + status, err := cursorCLIAuthStatus(context.Background(), "cursor-agent") + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized) + } +} + +func TestCursorCLIAuthStatusUnknownFromKeychainError(t *testing.T) { + restore := stubCursorAuthCommand(t, []string{"status"}, []byte("ERROR: SecItemCopyMatching failed -50\n"), assertErr("exit status 139")) + defer restore() + + status, err := cursorCLIAuthStatus(context.Background(), "cursor-agent") + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnknown) + } +} + +func TestCursorConfigAuthStatusAuthorizedWithAuthInfo(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli-config.json") + if err := os.WriteFile(path, []byte(`{"authInfo":{"email":"user@example.com","userId":"user-1","authId":"auth-1"}}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := cursorConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestCursorConfigAuthStatusUnknownWithoutAuthInfo(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli-config.json") + if err := os.WriteFile(path, []byte(`{"model":{"modelId":"cursor-default"}}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := cursorConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +type assertErr string + +func (e assertErr) Error() string { + return string(e) +} + +func stubCursorAuthCommand(t *testing.T, wantArgs []string, out []byte, err error) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + if name != "cursor-agent" || !reflect.DeepEqual(arg, wantArgs) { + t.Fatalf("command = %s %#v, want cursor-agent %#v", name, arg, wantArgs) + } + return out, err + } + return func() { authprobe.CmdRunner = previous } +} + +func TestCursorConfigAuthStatusUnknownWhenMissing(t *testing.T) { + status, ok, err := cursorConfigAuthStatus(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} diff --git a/backend/internal/adapters/agent/cursor/install.go b/backend/internal/adapters/agent/cursor/install.go new file mode 100644 index 000000000..eaf9f3ce0 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/install.go @@ -0,0 +1,8 @@ +package cursor + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.cursorBinary(ctx) +} diff --git a/backend/internal/adapters/agent/devin/auth.go b/backend/internal/adapters/agent/devin/auth.go new file mode 100644 index 000000000..0a3940b85 --- /dev/null +++ b/backend/internal/adapters/agent/devin/auth.go @@ -0,0 +1,62 @@ +package devin + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := devinLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, [][]string{{"auth", "status"}}) +} + +func devinLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return devinCredentialsAuthStatus(filepath.Join(home, ".local", "share", "devin", "credentials.toml")) +} + +func devinCredentialsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + text := strings.TrimSpace(string(data)) + if text == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + lower := strings.ToLower(text) + if strings.Contains(lower, "windsurf_api_key") || + strings.Contains(lower, "devin_api_url") || + strings.Contains(lower, "devin_webapp_host") { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} diff --git a/backend/internal/adapters/agent/devin/auth_test.go b/backend/internal/adapters/agent/devin/auth_test.go new file mode 100644 index 000000000..d33b3abee --- /dev/null +++ b/backend/internal/adapters/agent/devin/auth_test.go @@ -0,0 +1,61 @@ +package devin + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusAuthorizedFromAuthStatusOutput(t *testing.T) { + previous := authprobe.CmdRunner + authprobe.CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + if name != "devin" || !reflect.DeepEqual(arg, []string{"auth", "status"}) { + t.Fatalf("command = %s %#v, want devin auth status", name, arg) + } + return []byte("Logged in (via Devin).\n\nUser:\n Email: agentsubs@example.com\n"), nil + } + defer func() { authprobe.CmdRunner = previous }() + + got, err := (&Plugin{resolvedBinary: "devin"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestDevinCredentialsAuthStatusAuthorized(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.toml") + if err := os.WriteFile(path, []byte("windsurf_api_key = \"token\"\ndevin_api_url = \"https://api.devin.ai\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := devinCredentialsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestDevinCredentialsAuthStatusUnauthorizedWithEmptyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.toml") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := devinCredentialsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/devin/install.go b/backend/internal/adapters/agent/devin/install.go new file mode 100644 index 000000000..327c6154a --- /dev/null +++ b/backend/internal/adapters/agent/devin/install.go @@ -0,0 +1,8 @@ +package devin + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.devinBinary(ctx) +} diff --git a/backend/internal/adapters/agent/droid/auth.go b/backend/internal/adapters/agent/droid/auth.go new file mode 100644 index 000000000..b31caa15a --- /dev/null +++ b/backend/internal/adapters/agent/droid/auth.go @@ -0,0 +1,89 @@ +package droid + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if _, err := p.ResolveBinary(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } + status, ok, err := droidLocalAuthStatus(ctx) + if err != nil || ok { + return status, err + } + return ports.AgentAuthStatusUnauthorized, nil +} + +func droidLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("FACTORY_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return droidFactoryAuthStatus(filepath.Join(home, ".factory")) +} + +func droidFactoryAuthStatus(factoryDir string) (ports.AgentAuthStatus, bool, error) { + if factoryDir == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + if fileHasContent(filepath.Join(factoryDir, "auth.v2.file")) && fileHasContent(filepath.Join(factoryDir, "auth.v2.key")) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return droidSettingsAuthStatus(filepath.Join(factoryDir, "settings.json")) +} + +func droidSettingsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + var settings struct { + CustomModels []struct { + Model string `json:"model"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + } `json:"customModels"` + } + if err := json.Unmarshal(data, &settings); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, model := range settings.CustomModels { + if strings.TrimSpace(model.Model) != "" && + strings.TrimSpace(model.BaseURL) != "" && + strings.TrimSpace(model.APIKey) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + if len(settings.CustomModels) > 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func fileHasContent(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() && info.Size() > 0 +} diff --git a/backend/internal/adapters/agent/droid/auth_test.go b/backend/internal/adapters/agent/droid/auth_test.go new file mode 100644 index 000000000..56e777393 --- /dev/null +++ b/backend/internal/adapters/agent/droid/auth_test.go @@ -0,0 +1,72 @@ +package droid + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusAuthorizedFromFactoryAPIKey(t *testing.T) { + t.Setenv("FACTORY_API_KEY", "fk-test") + + got, err := (&Plugin{resolvedBinary: "droid"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestDroidFactoryAuthStatusAuthorizedFromAuthFiles(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "auth.v2.file"), []byte("encrypted auth"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "auth.v2.key"), []byte("key"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := droidFactoryAuthStatus(dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestDroidFactoryAuthStatusAuthorizedFromCustomModelAPIKey(t *testing.T) { + dir := t.TempDir() + settings := `{"customModels":[{"model":"claude-sonnet-4-5-20250929","baseUrl":"https://api.anthropic.com","apiKey":"sk-test"}],"model":"custom:Sonnet-0"}` + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := droidFactoryAuthStatus(dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestDroidFactoryAuthStatusUnauthorizedFromCustomModelWithoutAPIKey(t *testing.T) { + dir := t.TempDir() + settings := `{"customModels":[{"model":"claude-sonnet-4-5-20250929","baseUrl":"https://api.anthropic.com","apiKey":""}]}` + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := droidFactoryAuthStatus(dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/droid/install.go b/backend/internal/adapters/agent/droid/install.go new file mode 100644 index 000000000..b67c2e856 --- /dev/null +++ b/backend/internal/adapters/agent/droid/install.go @@ -0,0 +1,8 @@ +package droid + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.droidBinary(ctx) +} diff --git a/backend/internal/adapters/agent/goose/auth.go b/backend/internal/adapters/agent/goose/auth.go new file mode 100644 index 000000000..76afd2a9f --- /dev/null +++ b/backend/internal/adapters/agent/goose/auth.go @@ -0,0 +1,167 @@ +package goose + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + + yaml "gopkg.in/yaml.v3" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := gooseLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var gooseAPIKeyEnvVars = []string{ + "GOOSE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", +} + +func gooseLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range gooseAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + for _, path := range gooseConfigPaths() { + status, ok, err := gooseAuthStatusFromConfig(path) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if ok { + return status, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func gooseConfigPaths() []string { + seen := map[string]struct{}{} + paths := []string{} + add := func(path string) { + if path == "" { + return + } + if _, ok := seen[path]; ok { + return + } + seen[path] = struct{}{} + paths = append(paths, path) + } + + if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { + add(filepath.Join(xdg, "goose", "config.yaml")) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + // Goose stores config here on macOS as well, rather than under + // os.UserConfigDir's "Application Support" path. + add(filepath.Join(home, ".config", "goose", "config.yaml")) + } + return paths +} + +func gooseAuthStatusFromConfig(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if gooseConfigHasCredential(&root) || gooseConfigHasConfiguredProvider(&root) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func gooseConfigHasCredential(node *yaml.Node) bool { + if node == nil { + return false + } + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + if gooseConfigHasCredential(child) { + return true + } + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := strings.ToLower(strings.TrimSpace(node.Content[i].Value)) + value := strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`) + if (strings.Contains(key, "api_key") || strings.Contains(key, "apikey") || strings.Contains(key, "token")) && + value != "" && + !strings.EqualFold(value, "null") && + !strings.EqualFold(value, "none") { + return true + } + if gooseConfigHasCredential(node.Content[i+1]) { + return true + } + } + } + return false +} + +func gooseConfigHasConfiguredProvider(node *yaml.Node) bool { + if node == nil { + return false + } + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + if gooseConfigHasConfiguredProvider(child) { + return true + } + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := strings.ToLower(strings.TrimSpace(node.Content[i].Value)) + value := strings.ToLower(strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`)) + if key == "configured" && (value == "true" || value == "yes" || value == "1") { + return true + } + if gooseConfigHasConfiguredProvider(node.Content[i+1]) { + return true + } + } + } + return false +} diff --git a/backend/internal/adapters/agent/goose/goose.go b/backend/internal/adapters/agent/goose/goose.go index c0f9c5ab6..faa39963a 100644 --- a/backend/internal/adapters/agent/goose/goose.go +++ b/backend/internal/adapters/agent/goose/goose.go @@ -84,12 +84,15 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { // GetLaunchCommand builds the argv to start a new headless Goose session: // -// [env GOOSE_MODE=] goose run [--system ] [-t ] +// [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`. +// are passed via `--system`. Goose requires one of --instructions, --text, or +// --recipe even when AO intentionally starts a promptless orchestrator, so empty +// prompts are delivered as `-t "" --interactive` to land in an input-ready +// terminal without inventing an initial task. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.gooseBinary(ctx) if err != nil { @@ -106,8 +109,9 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( cmd = append(cmd, "--system", systemPrompt) } - if cfg.Prompt != "" { - cmd = append(cmd, "-t", cfg.Prompt) + cmd = append(cmd, "-t", cfg.Prompt) + if cfg.Prompt == "" { + cmd = append(cmd, "--interactive") } return cmd, nil diff --git a/backend/internal/adapters/agent/goose/goose_test.go b/backend/internal/adapters/agent/goose/goose_test.go index c5adc2016..8c2da8af1 100644 --- a/backend/internal/adapters/agent/goose/goose_test.go +++ b/backend/internal/adapters/agent/goose/goose_test.go @@ -71,6 +71,22 @@ func TestGetLaunchCommandSystemPromptFileInlined(t *testing.T) { } } +func TestGetLaunchCommandPromptlessLaunchStaysInteractive(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPrompt: "coordinate this project", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"goose", "run", "--system", "coordinate this project", "-t", "", "--interactive"} + 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 @@ -148,6 +164,73 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { } } +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + clearGooseAuthEnv(t) + t.Setenv("OPENROUTER_API_KEY", "test-key") + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusAuthorizedFromGooseConfig(t *testing.T) { + clearGooseAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + configPath := filepath.Join(home, ".config", "goose", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte("providers:\n openrouter:\n configured: true\n model: anthropic/claude-sonnet-4\n"), 0o600); err != nil { + t.Fatal(err) + } + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusUnauthorizedFromEmptyGooseConfig(t *testing.T) { + clearGooseAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + configPath := filepath.Join(home, ".config", "goose", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusUnauthorized) + } +} + +func clearGooseAuthEnv(t *testing.T) { + t.Helper() + for _, name := range gooseAPIKeyEnvVars { + t.Setenv(name, "") + } +} + func TestContextCancellationIsHonored(t *testing.T) { plugin := &Plugin{resolvedBinary: "goose"} ctx, cancel := context.WithCancel(context.Background()) diff --git a/backend/internal/adapters/agent/goose/install.go b/backend/internal/adapters/agent/goose/install.go new file mode 100644 index 000000000..3b3cb45ca --- /dev/null +++ b/backend/internal/adapters/agent/goose/install.go @@ -0,0 +1,8 @@ +package goose + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.gooseBinary(ctx) +} diff --git a/backend/internal/adapters/agent/grok/auth.go b/backend/internal/adapters/agent/grok/auth.go new file mode 100644 index 000000000..3f1fd402a --- /dev/null +++ b/backend/internal/adapters/agent/grok/auth.go @@ -0,0 +1,74 @@ +package grok + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := grokLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func grokLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("GROK_API_KEY")) != "" || strings.TrimSpace(os.Getenv("XAI_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + path := filepath.Join(home, ".grok", "auth.json") + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var entries map[string]json.RawMessage + if err := json.Unmarshal(data, &entries); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(entries) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + for key, value := range entries { + if strings.TrimSpace(key) == "" { + continue + } + trimmed := strings.TrimSpace(string(value)) + if trimmed != "" && trimmed != "null" && trimmed != "{}" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/grok/auth_test.go b/backend/internal/adapters/agent/grok/auth_test.go new file mode 100644 index 000000000..28ec92005 --- /dev/null +++ b/backend/internal/adapters/agent/grok/auth_test.go @@ -0,0 +1,76 @@ +package grok + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGrokLocalAuthStatusAuthorizedWithAPIKeyEnv(t *testing.T) { + t.Setenv("XAI_API_KEY", "xai-test") + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestGrokLocalAuthStatusAuthorizedWithAuthFile(t *testing.T) { + writeGrokAuthFile(t, `{ + "https://auth.x.ai::account": { + "access_token": "token", + "refresh_token": "refresh" + } + }`) + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestGrokLocalAuthStatusUnauthorizedWithEmptyAuthFile(t *testing.T) { + writeGrokAuthFile(t, `{}`) + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestGrokLocalAuthStatusUnknownWhenMissing(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func writeGrokAuthFile(t *testing.T, content string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + grokDir := filepath.Join(home, ".grok") + if err := os.MkdirAll(grokDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(grokDir, "auth.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/adapters/agent/grok/install.go b/backend/internal/adapters/agent/grok/install.go new file mode 100644 index 000000000..2af44fde4 --- /dev/null +++ b/backend/internal/adapters/agent/grok/install.go @@ -0,0 +1,8 @@ +package grok + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.grokBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kilocode/auth.go b/backend/internal/adapters/agent/kilocode/auth.go new file mode 100644 index 000000000..6e42c94c6 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/auth.go @@ -0,0 +1,194 @@ +package kilocode + +import ( + "context" + "database/sql" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + + _ "modernc.org/sqlite" // register sqlite driver for KiloCode auth database probes +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := kilocodeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + out, err := exec.CommandContext(probeCtx, binary, "auth", "list").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + status, ok := kilocodeAuthListStatus(string(out)) + if ok { + return status, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +var kilocodeAPIKeyEnvVars = []string{ + "KILO_API_KEY", + "KILOCODE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", +} + +func kilocodeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range kilocodeAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + dataDir, ok := kilocodeDataDir() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + authorized, known, err := kilocodeAuthJSONStatus(filepath.Join(dataDir, "auth.json")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if known && authorized { + return ports.AgentAuthStatusAuthorized, true, nil + } + authorized, known, err = kilocodeDBAuthStatus(ctx, filepath.Join(dataDir, "kilo.db")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if known && authorized { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func kilocodeDataDir() (string, bool) { + if dataDir := strings.TrimSpace(os.Getenv("KILO_DATA_DIR")); dataDir != "" { + return dataDir, true + } + if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" { + return filepath.Join(dataHome, "kilo"), true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".local", "share", "kilo"), true +} + +func kilocodeAuthJSONStatus(path string) (authorized, known bool, err error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return false, false, nil + } + if err != nil { + return false, false, err + } + if strings.TrimSpace(string(data)) == "" { + return false, false, nil + } + var providers map[string]map[string]any + if err := json.Unmarshal(data, &providers); err != nil { + return false, false, err + } + for _, provider := range providers { + if len(provider) == 0 { + continue + } + known = true + for _, key := range []string{"key", "apiKey", "api_key", "access_token", "token"} { + if strings.TrimSpace(asString(provider[key])) != "" { + return true, true, nil + } + } + } + return false, known, nil +} + +func asString(value any) string { + s, _ := value.(string) + return s +} + +func kilocodeDBAuthStatus(ctx context.Context, path string) (authorized, known bool, err error) { + if err := ctx.Err(); err != nil { + return false, false, err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return false, false, nil + } else if err != nil { + return false, false, err + } + + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro&_pragma=busy_timeout(1000)") + if err != nil { + return false, false, err + } + defer func() { + _ = db.Close() + }() + return kilocodeDBHasAuthorizedAccount(ctx, db) +} + +func kilocodeDBHasAuthorizedAccount(ctx context.Context, db *sql.DB) (authorized, known bool, err error) { + for _, query := range []string{ + `SELECT COUNT(*) FROM account_state WHERE active_account_id IS NOT NULL AND trim(active_account_id) != ''`, + `SELECT COUNT(*) FROM account WHERE trim(access_token) != ''`, + `SELECT COUNT(*) FROM control_account WHERE active = 1 AND trim(access_token) != ''`, + } { + var count int + if err := db.QueryRowContext(ctx, query).Scan(&count); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "no such table") { + continue + } + return false, false, err + } + known = true + if count > 0 { + return true, true, nil + } + } + return false, known, nil +} + +var kilocodeAuthListCountRE = regexp.MustCompile(`(?m)\b([1-9][0-9]*)\s+(credentials?|environment variables?)\b`) + +func kilocodeAuthListStatus(output string) (ports.AgentAuthStatus, bool) { + text := strings.ToLower(output) + if kilocodeAuthListCountRE.MatchString(text) { + return ports.AgentAuthStatusAuthorized, true + } + if strings.Contains(text, "0 credentials") && strings.Contains(text, "0 environment variable") { + return ports.AgentAuthStatusUnauthorized, true + } + return ports.AgentAuthStatusUnknown, false +} diff --git a/backend/internal/adapters/agent/kilocode/auth_test.go b/backend/internal/adapters/agent/kilocode/auth_test.go new file mode 100644 index 000000000..90947b345 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/auth_test.go @@ -0,0 +1,165 @@ +package kilocode + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestKilocodeLocalAuthStatusAuthorizedWithProviderEnv(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "openai-key") + + status, ok, err := kilocodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKilocodeAuthListStatusAuthorizedWithEnvironmentVariable(t *testing.T) { + output := ` +log stream error: EPERM: operation not permitted +Credentials ~/.local/share/kilo/auth.json +0 credentials + +Environment +OpenAI OPENAI_API_KEY +1 environment variable +` + status, ok := kilocodeAuthListStatus(output) + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKilocodeAuthListStatusAuthorizedWithCredentials(t *testing.T) { + status, ok := kilocodeAuthListStatus("2 credentials\n0 environment variables") + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKilocodeAuthJSONStatusAuthorizedWithProviderKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(path, []byte(`{"zai":{"type":"api","key":"secret"}}`), 0o600); err != nil { + t.Fatal(err) + } + + authorized, known, err := kilocodeAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !known || !authorized { + t.Fatalf("authorized, known = %v, %v; want true, true", authorized, known) + } +} + +func TestKilocodeAuthJSONStatusUnknownWhenEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(path, []byte(`{"zai":{"type":"api","key":""}}`), 0o600); err != nil { + t.Fatal(err) + } + + authorized, known, err := kilocodeAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !known || authorized { + t.Fatalf("authorized, known = %v, %v; want false, true", authorized, known) + } +} + +func TestKilocodeDBHasAuthorizedAccount(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + CREATE TABLE account_state ( + id integer PRIMARY KEY NOT NULL, + active_account_id text, + active_org_id text + ); + INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) + VALUES ('acct_1', 'user@example.com', 'https://kilo.ai', 'token', 'refresh', 1, 1); + INSERT INTO account_state (id, active_account_id) VALUES (1, 'acct_1'); + `); err != nil { + t.Fatal(err) + } + + authorized, known, err := kilocodeDBHasAuthorizedAccount(context.Background(), db) + if err != nil { + t.Fatal(err) + } + if !known || !authorized { + t.Fatalf("authorized, known = %v, %v; want true, true", authorized, known) + } +} + +func TestAuthStatusUnknownWhenKeyOnlyComesFromInteractiveShell(t *testing.T) { + dir := t.TempDir() + shellPath := filepath.Join(dir, "fake-shell") + if err := os.WriteFile(shellPath, []byte(`#!/bin/sh +/usr/bin/touch "$AO_SHELL_PROBE_MARKER" +if [ "$1" = "-ic" ]; then + OPENAI_API_KEY=from-shell /bin/sh -c "$2" +fi +`), 0o755); err != nil { + t.Fatal(err) + } + kilocodePath := filepath.Join(dir, "kilocode") + if err := os.WriteFile(kilocodePath, []byte(`#!/bin/sh +if [ "$1" = "auth" ] && [ "$2" = "list" ]; then + printf 'auth status unavailable\n' + exit 1 +fi +exit 1 +`), 0o755); err != nil { + t.Fatal(err) + } + markerPath := filepath.Join(dir, "shell-probe-marker") + + t.Setenv("SHELL", shellPath) + t.Setenv("PATH", dir) + t.Setenv("KILO_DATA_DIR", filepath.Join(dir, "missing-kilo-data")) + t.Setenv("AO_SHELL_PROBE_MARKER", markerPath) + for _, name := range kilocodeAPIKeyEnvVars { + t.Setenv(name, "") + } + + status, err := (&Plugin{resolvedBinary: kilocodePath}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnknown) + } + if _, err := os.Stat(markerPath); !os.IsNotExist(err) { + t.Fatalf("interactive shell probe ran; marker stat error = %v", err) + } +} + +func TestKilocodeAuthListStatusUnauthorizedWhenEmpty(t *testing.T) { + status, ok := kilocodeAuthListStatus("0 credentials\n0 environment variables") + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/kilocode/install.go b/backend/internal/adapters/agent/kilocode/install.go new file mode 100644 index 000000000..c2c651602 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/install.go @@ -0,0 +1,8 @@ +package kilocode + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.kilocodeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kimi/auth.go b/backend/internal/adapters/agent/kimi/auth.go new file mode 100644 index 000000000..2ec795eb7 --- /dev/null +++ b/backend/internal/adapters/agent/kimi/auth.go @@ -0,0 +1,88 @@ +package kimi + +import ( + "context" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := kimiLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var kimiAPIKeyEnvVars = []string{ + "KIMI_API_KEY", + "KIMI_CODE_API_KEY", + "MOONSHOT_API_KEY", +} + +func kimiLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range kimiAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + home, ok := kimiCodeHome() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + return kimiConfigAuthStatus(filepath.Join(home, "config.toml")) +} + +func kimiCodeHome() (string, bool) { + if home := strings.TrimSpace(os.Getenv("KIMI_CODE_HOME")); home != "" { + return home, true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".kimi-code"), true +} + +var kimiAPIKeyLineRE = regexp.MustCompile(`(?m)^\s*api_key\s*=\s*("([^"]*)"|'([^']*)'|([^\s#]+))`) + +func kimiConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + matches := kimiAPIKeyLineRE.FindAllStringSubmatch(string(data), -1) + if len(matches) == 0 { + return ports.AgentAuthStatusUnknown, false, nil + } + for _, match := range matches { + for _, group := range match[2:] { + if strings.TrimSpace(group) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/kimi/auth_test.go b/backend/internal/adapters/agent/kimi/auth_test.go new file mode 100644 index 000000000..f060ebeb3 --- /dev/null +++ b/backend/internal/adapters/agent/kimi/auth_test.go @@ -0,0 +1,79 @@ +package kimi + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestKimiLocalAuthStatusAuthorizedWithEnvKey(t *testing.T) { + t.Setenv("KIMI_API_KEY", "kimi-key") + + status, ok, err := kimiLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKimiLocalAuthStatusUsesKimiCodeHome(t *testing.T) { + home := t.TempDir() + t.Setenv("KIMI_CODE_HOME", home) + if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(` +[providers.zai-coding-plan] +type = "openai-compatible" +api_key = "secret" +base_url = "https://api.z.ai/api/coding/paas/v4" +`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := kimiLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKimiConfigAuthStatusAuthorizedWithProviderAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(` +[providers.zai-coding-plan] +api_key = "secret" +`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := kimiConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKimiConfigAuthStatusUnauthorizedWithEmptyAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(` +[providers.zai-coding-plan] +api_key = "" +`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := kimiConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/kimi/install.go b/backend/internal/adapters/agent/kimi/install.go new file mode 100644 index 000000000..a36b10d66 --- /dev/null +++ b/backend/internal/adapters/agent/kimi/install.go @@ -0,0 +1,8 @@ +package kimi + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.kimiBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kiro/auth.go b/backend/internal/adapters/agent/kiro/auth.go new file mode 100644 index 000000000..f68b22b98 --- /dev/null +++ b/backend/internal/adapters/agent/kiro/auth.go @@ -0,0 +1,36 @@ +package kiro + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.kiroBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + return kiroWhoamiAuthStatus(ctx, binary) +} + +func kiroWhoamiAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + out, err := authprobe.CmdRunner(ctx, binary, "whoami") + if ctx.Err() != nil { + return ports.AgentAuthStatusUnknown, ctx.Err() + } + if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} diff --git a/backend/internal/adapters/agent/kiro/install.go b/backend/internal/adapters/agent/kiro/install.go new file mode 100644 index 000000000..4010ea295 --- /dev/null +++ b/backend/internal/adapters/agent/kiro/install.go @@ -0,0 +1,8 @@ +package kiro + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.kiroBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kiro/kiro_test.go b/backend/internal/adapters/agent/kiro/kiro_test.go index 6cd25c9ac..61dfab11a 100644 --- a/backend/internal/adapters/agent/kiro/kiro_test.go +++ b/backend/internal/adapters/agent/kiro/kiro_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -136,6 +137,44 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { } } +func TestAuthStatusUsesKiroWhoami(t *testing.T) { + restore := stubKiroAuthRunner(t, func(_ context.Context, name string, arg ...string) ([]byte, error) { + if name != "kiro-cli" { + t.Fatalf("binary = %q, want kiro-cli", name) + } + if !reflect.DeepEqual(arg, []string{"whoami"}) { + t.Fatalf("args = %#v, want [whoami]", arg) + } + return []byte("Logged in with Google\nEmail: nicachale456@gmail.com\n"), nil + }) + defer restore() + + plugin := &Plugin{resolvedBinary: "kiro-cli"} + status, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusUnauthorizedFromKiroWhoami(t *testing.T) { + restore := stubKiroAuthRunner(t, func(_ context.Context, _ string, _ ...string) ([]byte, error) { + return []byte("Not logged in\n"), nil + }) + defer restore() + + plugin := &Plugin{resolvedBinary: "kiro-cli"} + status, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnauthorized) + } +} + func TestGetAgentHooksInstallsKiroHooks(t *testing.T) { plugin := &Plugin{resolvedBinary: "kiro-cli"} workspace := t.TempDir() @@ -441,6 +480,13 @@ func containsSubsequence(values []string, needle []string) bool { return false } +func stubKiroAuthRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = runner + return func() { authprobe.CmdRunner = previous } +} + func countKiroHookCommand(entries []kiroHookEntry, command string) int { count := 0 for _, entry := range entries { diff --git a/backend/internal/adapters/agent/opencode/install.go b/backend/internal/adapters/agent/opencode/install.go new file mode 100644 index 000000000..b49f37c3f --- /dev/null +++ b/backend/internal/adapters/agent/opencode/install.go @@ -0,0 +1,8 @@ +package opencode + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.opencodeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/opencode/opencode.go b/backend/internal/adapters/agent/opencode/opencode.go index 377f1bde3..415125998 100644 --- a/backend/internal/adapters/agent/opencode/opencode.go +++ b/backend/internal/adapters/agent/opencode/opencode.go @@ -17,15 +17,21 @@ package opencode import ( "context" + "database/sql" + "encoding/json" + "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + + _ "modernc.org/sqlite" // register sqlite driver for opencode session metadata probes ) const ( @@ -55,6 +61,7 @@ func New() *Plugin { var _ adapters.Adapter = (*Plugin)(nil) var _ ports.Agent = (*Plugin)(nil) +var _ ports.AgentAuthChecker = (*Plugin)(nil) // Manifest returns the adapter's static self-description. func (p *Plugin) Manifest() adapters.Manifest { @@ -158,6 +165,187 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks whether opencode has at least one configured provider +// credential. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.opencodeBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := opencodeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "auth", "list").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + text := strings.ToLower(string(out)) + if strings.Contains(text, "0 credentials") { + return ports.AgentAuthStatusUnauthorized, nil + } + if strings.Contains(text, "credential") && err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +var opencodeAPIKeyEnvVars = []string{ + "OPENCODE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", +} + +func opencodeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range opencodeAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + dataDir, ok := opencodeDataDir() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + jsonStatus, jsonOK, err := opencodeAuthJSONStatus(filepath.Join(dataDir, "auth.json")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if jsonOK && jsonStatus == ports.AgentAuthStatusAuthorized { + return jsonStatus, true, nil + } + if status, ok, err := opencodeDBAuthStatus(ctx, filepath.Join(dataDir, "opencode.db")); err != nil || ok { + return status, ok, err + } + if jsonOK { + return jsonStatus, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func opencodeDataDir() (string, bool) { + if dataDir := strings.TrimSpace(os.Getenv("OPENCODE_DATA_DIR")); dataDir != "" { + return dataDir, true + } + if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" { + return filepath.Join(dataHome, "opencode"), true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".local", "share", "opencode"), true +} + +func opencodeAuthJSONStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var entries map[string]json.RawMessage + if err := json.Unmarshal(data, &entries); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(entries) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + for key, value := range entries { + if strings.TrimSpace(key) == "" { + continue + } + trimmed := strings.TrimSpace(string(value)) + if trimmed != "" && trimmed != "null" && trimmed != "{}" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} + +func opencodeDBAuthStatus(ctx context.Context, path string) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } else if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro&_pragma=busy_timeout(1000)") + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + defer func() { + _ = db.Close() + }() + + authorized, known, err := opencodeDBHasAuthorizedAccount(ctx, db) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if !known { + return ports.AgentAuthStatusUnknown, false, nil + } + if authorized { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil +} + +func opencodeDBHasAuthorizedAccount(ctx context.Context, db *sql.DB) (authorized, known bool, err error) { + for _, query := range []string{ + `SELECT COUNT(*) FROM account_state WHERE active_account_id IS NOT NULL AND trim(active_account_id) != ''`, + `SELECT COUNT(*) FROM account WHERE trim(access_token) != ''`, + `SELECT COUNT(*) FROM control_account WHERE active = 1 AND trim(access_token) != ''`, + } { + count, err := opencodeDBCount(ctx, db, query) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "no such table") { + continue + } + return false, false, err + } + known = true + if count > 0 { + return true, true, nil + } + } + return false, known, nil +} + +func opencodeDBCount(ctx context.Context, db *sql.DB, query string) (int, error) { + var count int + if err := db.QueryRowContext(ctx, query).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + // appendPermissionFlags maps AO's permission modes onto opencode's single // approval flag. opencode exposes only --dangerously-skip-permissions (no // graduated accept-edits/auto modes), so: @@ -185,9 +373,7 @@ func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { // ResolveOpenCodeBinary returns the path to the opencode binary on this machine, // searching PATH then a handful of well-known install locations (the install -// script's ~/.opencode/bin, Homebrew, npm global). Returns "opencode" as a -// last-ditch fallback so callers see a clear "command not found" rather than an -// empty argv. +// script's ~/.opencode/bin, Homebrew, npm global). func ResolveOpenCodeBinary(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -211,7 +397,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) { return candidate, nil } } - return "opencode", nil + return "", fmt.Errorf("opencode: %w", ports.ErrAgentBinaryNotFound) } if path, err := exec.LookPath("opencode"); err == nil && path != "" { @@ -238,7 +424,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) { } } - return "opencode", nil + return "", fmt.Errorf("opencode: %w", ports.ErrAgentBinaryNotFound) } func (p *Plugin) opencodeBinary(ctx context.Context) (string, error) { diff --git a/backend/internal/adapters/agent/opencode/opencode_test.go b/backend/internal/adapters/agent/opencode/opencode_test.go index ba73297c1..20a19326d 100644 --- a/backend/internal/adapters/agent/opencode/opencode_test.go +++ b/backend/internal/adapters/agent/opencode/opencode_test.go @@ -2,6 +2,8 @@ package opencode import ( "context" + "database/sql" + "errors" "os" "path/filepath" "reflect" @@ -11,6 +13,279 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) +func TestOpenCodeLocalAuthStatusAuthorizedWithEnv(t *testing.T) { + clearOpenCodeAuthEnv(t) + t.Setenv("ANTHROPIC_API_KEY", "sk-ant-test") + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusAuthorizedWithAuthFile(t *testing.T) { + clearOpenCodeAuthEnv(t) + writeOpenCodeAuthFile(t, `{ + "anthropic": { + "type": "api", + "key": "sk-ant-test" + } + }`) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusUnauthorizedWithEmptyAuthFile(t *testing.T) { + clearOpenCodeAuthEnv(t) + writeOpenCodeAuthFile(t, `{}`) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestOpenCodeLocalAuthStatusAuthorizedWithActiveDBAccount(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataDir := writeOpenCodeDB(t, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + CREATE TABLE account_state ( + id integer PRIMARY KEY NOT NULL, + active_account_id text, + active_org_id text + ); + INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) + VALUES ('acct_1', 'user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1); + INSERT INTO account_state (id, active_account_id) VALUES (1, 'acct_1'); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("OPENCODE_DATA_DIR", dataDir) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusDBAccountOverridesEmptyAuthFile(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataDir := writeOpenCodeDB(t, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) + VALUES ('acct_1', 'user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("OPENCODE_DATA_DIR", dataDir) + if err := os.WriteFile(filepath.Join(dataDir, "auth.json"), []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusAuthorizedWithControlDBAccount(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataHome := t.TempDir() + dataDir := filepath.Join(dataHome, "opencode") + writeOpenCodeDBAt(t, dataDir, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE control_account ( + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + active integer NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + PRIMARY KEY(email, url) + ); + INSERT INTO control_account (email, url, access_token, refresh_token, active, time_created, time_updated) + VALUES ('user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1, 1); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("XDG_DATA_HOME", dataHome) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusUnauthorizedWithEmptyDBAccounts(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataDir := writeOpenCodeDB(t, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + CREATE TABLE account_state ( + id integer PRIMARY KEY NOT NULL, + active_account_id text, + active_org_id text + ); + CREATE TABLE control_account ( + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + active integer NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + PRIMARY KEY(email, url) + ); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("OPENCODE_DATA_DIR", dataDir) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestOpenCodeLocalAuthStatusUnknownWhenMissing(t *testing.T) { + clearOpenCodeAuthEnv(t) + t.Setenv("HOME", t.TempDir()) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func writeOpenCodeDB(t *testing.T, setup func(*sql.DB)) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + dataDir := filepath.Join(home, ".local", "share", "opencode") + writeOpenCodeDBAt(t, dataDir, setup) + return dataDir +} + +func writeOpenCodeDBAt(t *testing.T, dataDir string, setup func(*sql.DB)) { + t.Helper() + if err := os.MkdirAll(dataDir, 0o700); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(filepath.Join(dataDir, "opencode.db"))+"?mode=rwc") + if err != nil { + t.Fatal(err) + } + defer db.Close() + setup(db) +} + +func writeOpenCodeAuthFile(t *testing.T, content string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + authDir := filepath.Join(home, ".local", "share", "opencode") + if err := os.MkdirAll(authDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(authDir, "auth.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func clearOpenCodeAuthEnv(t *testing.T) { + t.Helper() + for _, name := range opencodeAPIKeyEnvVars { + t.Setenv(name, "") + } + t.Setenv("OPENCODE_DATA_DIR", "") + t.Setenv("XDG_DATA_HOME", "") +} + +func TestResolveOpenCodeBinaryFallback(t *testing.T) { + bin, err := ResolveOpenCodeBinary(context.Background()) + if err != nil { + if !errors.Is(err, ports.ErrAgentBinaryNotFound) { + t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err) + } + return + } + if bin == "" { + t.Fatal("ResolveOpenCodeBinary returned empty path with no error") + } +} + +func TestResolveOpenCodeBinaryContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := ResolveOpenCodeBinary(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("ResolveOpenCodeBinary err = %v, want context.Canceled", err) + } +} + func TestGetLaunchCommandBuildsArgv(t *testing.T) { plugin := &Plugin{resolvedBinary: "opencode"} diff --git a/backend/internal/adapters/agent/pi/auth.go b/backend/internal/adapters/agent/pi/auth.go new file mode 100644 index 000000000..035bf3a50 --- /dev/null +++ b/backend/internal/adapters/agent/pi/auth.go @@ -0,0 +1,85 @@ +package pi + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := piLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func piLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + configDir, ok := piConfigDir() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + return piAuthJSONStatus(filepath.Join(configDir, "auth.json")) +} + +func piConfigDir() (string, bool) { + if configDir := strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")); configDir != "" { + return configDir, true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".pi", "agent"), true +} + +type piAuthEntry struct { + Type string `json:"type"` + Key string `json:"key"` +} + +func piAuthJSONStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var entries map[string]piAuthEntry + if err := json.Unmarshal(data, &entries); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(entries) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + for provider, entry := range entries { + if strings.TrimSpace(provider) == "" { + continue + } + if strings.TrimSpace(entry.Key) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/pi/auth_test.go b/backend/internal/adapters/agent/pi/auth_test.go new file mode 100644 index 000000000..3a04737e4 --- /dev/null +++ b/backend/internal/adapters/agent/pi/auth_test.go @@ -0,0 +1,42 @@ +package pi + +import ( + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestPiAuthJSONStatusAuthorizedWithProviderKey(t *testing.T) { + path := writePiAuthJSON(t, `{"zai":{"type":"api_key","key":"test-key"}}`) + + status, ok, err := piAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestPiAuthJSONStatusUnauthorizedWhenEmpty(t *testing.T) { + path := writePiAuthJSON(t, `{}`) + + status, ok, err := piAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func writePiAuthJSON(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/backend/internal/adapters/agent/pi/install.go b/backend/internal/adapters/agent/pi/install.go new file mode 100644 index 000000000..54130bbef --- /dev/null +++ b/backend/internal/adapters/agent/pi/install.go @@ -0,0 +1,8 @@ +package pi + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.piBinary(ctx) +} diff --git a/backend/internal/adapters/agent/qwen/auth.go b/backend/internal/adapters/agent/qwen/auth.go new file mode 100644 index 000000000..7f69a5a9e --- /dev/null +++ b/backend/internal/adapters/agent/qwen/auth.go @@ -0,0 +1,116 @@ +package qwen + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := qwenLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var qwenAPIKeyEnvVars = []string{ + "QWEN_API_KEY", + "BAILIAN_CODING_PLAN_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "REQUESTY_API_KEY", + "DASHSCOPE_API_KEY", + "ZAI_API_KEY", +} + +func qwenLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range qwenAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return qwenAuthStatusFromSettings(filepath.Join(home, ".qwen", "settings.json")) +} + +func qwenAuthStatusFromSettings(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var root any + if err := json.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if containsQwenAPIKey(root) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func containsQwenAPIKey(value any) bool { + switch v := value.(type) { + case map[string]any: + for key, child := range v { + if strings.EqualFold(key, "apiKey") || strings.EqualFold(key, "apikey") { + if stringSetting(child) != "" { + return true + } + continue + } + if containsQwenAPIKey(child) { + return true + } + } + case []any: + for _, child := range v { + if containsQwenAPIKey(child) { + return true + } + } + } + return false +} + +func stringSetting(value any) string { + text, ok := value.(string) + if !ok { + return "" + } + text = strings.TrimSpace(text) + if text == "" || strings.EqualFold(text, "null") || strings.EqualFold(text, "none") { + return "" + } + return text +} diff --git a/backend/internal/adapters/agent/qwen/auth_test.go b/backend/internal/adapters/agent/qwen/auth_test.go new file mode 100644 index 000000000..1fe791325 --- /dev/null +++ b/backend/internal/adapters/agent/qwen/auth_test.go @@ -0,0 +1,78 @@ +package qwen + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestQwenLocalAuthStatusAuthorizedWithProviderEnv(t *testing.T) { + t.Setenv("ZAI_API_KEY", "zai-key") + + status, ok, err := qwenLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestQwenAuthStatusFromSettingsAuthorizedWithModelProviderAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + content := `{ + "modelProviders": { + "zai": { + "baseUrl": "https://api.z.ai/api/coding/paas/v4", + "apiKey": "zai-key" + } + }, + "defaultModel": "glm-4.5" + }` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := qwenAuthStatusFromSettings(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestQwenAuthStatusFromSettingsAuthorizedWithSecurityAuthAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + content := `{ + "security": { + "auth": { + "apiKey": "openai-compatible-key" + } + } + }` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := qwenAuthStatusFromSettings(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestQwenAuthStatusFromSettingsUnknownWhenMissing(t *testing.T) { + status, ok, err := qwenAuthStatusFromSettings(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} diff --git a/backend/internal/adapters/agent/qwen/install.go b/backend/internal/adapters/agent/qwen/install.go new file mode 100644 index 000000000..e54ef99dc --- /dev/null +++ b/backend/internal/adapters/agent/qwen/install.go @@ -0,0 +1,8 @@ +package qwen + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.qwenBinary(ctx) +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index 77f9b5264..91141e9d9 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -83,8 +83,9 @@ func Build() (*adapters.Registry, error) { // harness is the adapter's manifest id, which is also the domain.AgentHarness // value a session carries and the `--harness` flag users pass. type HarnessAgent struct { - Harness domain.AgentHarness - Agent ports.Agent + Harness domain.AgentHarness + Manifest adapters.Manifest + Agent ports.Agent } // Harnessed returns every shipped adapter that drives an agent, paired with its @@ -99,8 +100,9 @@ func Harnessed() []HarnessAgent { continue } out = append(out, HarnessAgent{ - Harness: domain.AgentHarness(a.Manifest().ID), - Agent: agent, + Harness: domain.AgentHarness(a.Manifest().ID), + Manifest: a.Manifest(), + Agent: agent, }) } return out diff --git a/backend/internal/adapters/agent/registry/registry_test.go b/backend/internal/adapters/agent/registry/registry_test.go index 269abced9..25cef3eed 100644 --- a/backend/internal/adapters/agent/registry/registry_test.go +++ b/backend/internal/adapters/agent/registry/registry_test.go @@ -23,6 +23,9 @@ func TestGetAgentHooksFootprintIsGitignored(t *testing.T) { for _, ha := range Harnessed() { t.Run(string(ha.Harness), func(t *testing.T) { ws := t.TempDir() + if ha.Harness == "autohand" { + t.Setenv("AUTOHAND_CONFIG", filepath.Join(t.TempDir(), "config.json")) + } cfg := ports.WorkspaceHookConfig{ SessionID: "proj-1", WorkspacePath: ws, @@ -52,6 +55,23 @@ func TestGetAgentHooksFootprintIsGitignored(t *testing.T) { } } +func TestEveryHarnessReportsAuthStatus(t *testing.T) { + authCheckerExempt := map[string]string{ + "continue": "Continue auth probes require sending a model prompt, so catalog refresh must not run them", + } + for _, ha := range Harnessed() { + if reason, exempt := authCheckerExempt[string(ha.Harness)]; exempt { + if _, ok := ha.Agent.(ports.AgentAuthChecker); ok { + t.Errorf("%s implements ports.AgentAuthChecker but is exempt: %s", ha.Harness, reason) + } + continue + } + if _, ok := ha.Agent.(ports.AgentAuthChecker); !ok { + t.Errorf("%s does not implement ports.AgentAuthChecker", ha.Harness) + } + } +} + // workspaceFiles returns every regular file under root, relative to root. func workspaceFiles(t *testing.T, root string) []string { t.Helper() diff --git a/backend/internal/adapters/agent/vibe/auth.go b/backend/internal/adapters/agent/vibe/auth.go new file mode 100644 index 000000000..b05a6d186 --- /dev/null +++ b/backend/internal/adapters/agent/vibe/auth.go @@ -0,0 +1,181 @@ +package vibe + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := vibeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +const ( + // This names the default env var Vibe reads; it is not a credential value. + vibeDefaultAPIKeyEnvVar = "MISTRAL_API_KEY" //nolint:gosec // env var name, not a credential value + vibeKeychainService = "vibe" +) + +func vibeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + vibeHome := os.Getenv("VIBE_HOME") + if strings.TrimSpace(vibeHome) == "" { + vibeHome = filepath.Join(home, ".vibe") + } + + envVars, err := vibeAPIKeyEnvVars(filepath.Join(vibeHome, "config.toml")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, envVar := range envVars { + if strings.TrimSpace(os.Getenv(envVar)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + if status, ok, err := vibeEnvFileAuthStatus(filepath.Join(vibeHome, ".env"), envVar); err != nil || ok { + return status, ok, err + } + } + if status, ok, err := vibeSessionLogAuthStatus(ctx, filepath.Join(vibeHome, "logs", "session")); err != nil || ok { + return status, ok, err + } + for _, envVar := range envVars { + if status, ok, err := vibeKeychainAuthStatus(ctx, envVar); err != nil || ok { + return status, ok, err + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeAPIKeyEnvVars(configPath string) ([]string, error) { + vars := []string{vibeDefaultAPIKeyEnvVar, "VIBE_CODE_API_KEY"} + data, err := os.ReadFile(configPath) + if os.IsNotExist(err) { + return vars, nil + } + if err != nil { + return nil, err + } + for _, line := range strings.Split(string(data), "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok || strings.TrimSpace(key) != "api_key_env_var" { + continue + } + envVar := strings.Trim(strings.TrimSpace(value), `"',`) + if envVar != "" && !strings.EqualFold(envVar, "null") && !containsString(vars, envVar) { + vars = append(vars, envVar) + } + } + return vars, nil +} + +func vibeEnvFileAuthStatus(path, envVar string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(key) != envVar { + continue + } + if strings.Trim(strings.TrimSpace(value), `"'`) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeKeychainAuthStatus(ctx context.Context, envVar string) (ports.AgentAuthStatus, bool, error) { + if strings.TrimSpace(envVar) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + //nolint:gosec // invokes macOS security with fixed command and validated account/service arguments + out, err := exec.CommandContext(probeCtx, "security", "find-generic-password", "-s", vibeKeychainService, "-a", envVar, "-w").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, false, nil + } + if err == nil && strings.TrimSpace(string(out)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeSessionLogAuthStatus(ctx context.Context, dir string) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + paths, err := filepath.Glob(filepath.Join(dir, "session_*", "messages.jsonl")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, path := range paths { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if vibeMessagesShowModelUse(string(data)) { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeMessagesShowModelUse(text string) bool { + return strings.Contains(text, `"role": "assistant"`) || + strings.Contains(text, `"role":"assistant"`) || + strings.Contains(text, `"reasoning_content"`) || + strings.Contains(text, `"session_completion_tokens"`) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/vibe/install.go b/backend/internal/adapters/agent/vibe/install.go new file mode 100644 index 000000000..3ed8bb0c9 --- /dev/null +++ b/backend/internal/adapters/agent/vibe/install.go @@ -0,0 +1,8 @@ +package vibe + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.vibeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/vibe/vibe.go b/backend/internal/adapters/agent/vibe/vibe.go index a838349ac..d002a5e57 100644 --- a/backend/internal/adapters/agent/vibe/vibe.go +++ b/backend/internal/adapters/agent/vibe/vibe.go @@ -78,12 +78,14 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { // GetLaunchCommand builds the argv to start a new non-interactive Vibe session: // -// vibe --trust --output text [--agent ] -p +// vibe --trust --output text [--workdir ] [--agent ] -p // // The prompt is delivered through `-p` (programmatic mode), so AO uses // in-command delivery. `--trust` skips the trust prompt for automation and -// `--output text` pins the output format. Vibe exposes no CLI system-prompt -// flag (system prompts are config-driven), so SystemPrompt is not forwarded. +// `--output text` pins the output format. `--workdir` is passed explicitly +// because Vibe validates its own working directory in addition to the process +// cwd AO sets through the runtime. Vibe exposes no CLI system-prompt flag +// (system prompts are config-driven), so SystemPrompt is not forwarded. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { if err := ctx.Err(); err != nil { return nil, err @@ -94,6 +96,7 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( } cmd = []string{binary, "--trust", "--output", "text"} + appendWorkdirFlag(&cmd, cfg.WorkspacePath) appendAgentFlags(&cmd, cfg.Permissions) if cfg.Prompt != "" { cmd = append(cmd, "-p", cfg.Prompt) @@ -134,6 +137,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) } cmd = make([]string, 0, 8) cmd = append(cmd, binary, "--trust", "--output", "text") + appendWorkdirFlag(&cmd, cfg.Session.WorkspacePath) appendAgentFlags(&cmd, cfg.Permissions) cmd = append(cmd, "--resume", agentSessionID) return cmd, true, nil @@ -148,6 +152,12 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return ports.SessionInfo{}, false, nil } +func appendWorkdirFlag(cmd *[]string, workspacePath string) { + if workspacePath != "" { + *cmd = append(*cmd, "--workdir", workspacePath) + } +} + // appendAgentFlags maps AO permission modes onto Vibe's builtin `--agent` // profiles. PermissionModeDefault (and the empty mode) emit no flag so Vibe // resolves its starting agent from the user's `default_agent` config. diff --git a/backend/internal/adapters/agent/vibe/vibe_test.go b/backend/internal/adapters/agent/vibe/vibe_test.go index 06d8deef0..baae1024d 100644 --- a/backend/internal/adapters/agent/vibe/vibe_test.go +++ b/backend/internal/adapters/agent/vibe/vibe_test.go @@ -3,6 +3,8 @@ package vibe import ( "context" "errors" + "os" + "path/filepath" "reflect" "testing" @@ -39,6 +41,91 @@ func TestGetConfigSpecEmpty(t *testing.T) { } } +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + clearVibeAuthEnv(t, vibeDefaultAPIKeyEnvVar, "VIBE_CODE_API_KEY") + t.Setenv(vibeDefaultAPIKeyEnvVar, "test-key") + p := &Plugin{resolvedBinary: "vibe"} + + got, err := p.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestVibeAPIKeyEnvVarsReadsConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(configPath, []byte("[[providers]]\napi_key_env_var = \"CUSTOM_VIBE_KEY\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + got, err := vibeAPIKeyEnvVars(configPath) + if err != nil { + t.Fatal(err) + } + if !containsString(got, vibeDefaultAPIKeyEnvVar) || !containsString(got, "CUSTOM_VIBE_KEY") { + t.Fatalf("vibeAPIKeyEnvVars = %#v, want default and custom key", got) + } +} + +func TestVibeEnvFileAuthStatusAuthorized(t *testing.T) { + envPath := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(envPath, []byte("MISTRAL_API_KEY=test-key\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := vibeEnvFileAuthStatus(envPath, vibeDefaultAPIKeyEnvVar) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestVibeEnvFileAuthStatusUnauthorizedForEmptyValue(t *testing.T) { + envPath := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(envPath, []byte("MISTRAL_API_KEY=\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := vibeEnvFileAuthStatus(envPath, vibeDefaultAPIKeyEnvVar) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestVibeSessionLogAuthStatusAuthorizedWithAssistantMessage(t *testing.T) { + dir := t.TempDir() + sessionDir := filepath.Join(dir, "session_20260625_071829_d5e8a6eb") + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, "messages.jsonl"), []byte(`{"role":"assistant","content":"Hello"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := vibeSessionLogAuthStatus(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func clearVibeAuthEnv(t *testing.T, names ...string) { + t.Helper() + for _, name := range names { + t.Setenv(name, "") + } +} + func TestGetPromptDeliveryStrategy(t *testing.T) { s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -52,14 +139,15 @@ func TestGetPromptDeliveryStrategy(t *testing.T) { func TestGetLaunchCommandWithPrompt(t *testing.T) { p := &Plugin{resolvedBinary: "vibe"} cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ - Permissions: ports.PermissionModeBypassPermissions, - Prompt: "add a health check", + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "add a health check", + WorkspacePath: "/work/repo", }) if err != nil { t.Fatal(err) } - want := []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve", "-p", "add a health check"} + want := []string{"vibe", "--trust", "--output", "text", "--workdir", "/work/repo", "--agent", "auto-approve", "-p", "add a health check"} if !reflect.DeepEqual(cmd, want) { t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) } @@ -124,7 +212,8 @@ func TestGetRestoreCommand(t *testing.T) { p := &Plugin{resolvedBinary: "vibe"} cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ Session: ports.SessionRef{ - Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abcd1234-5678-90ab-cdef-1234567890ab"}, + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abcd1234-5678-90ab-cdef-1234567890ab"}, + WorkspacePath: "/work/repo", }, Permissions: ports.PermissionModeBypassPermissions, }) @@ -135,7 +224,7 @@ func TestGetRestoreCommand(t *testing.T) { t.Fatal("ok=false, want true") } - want := []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve", "--resume", "abcd1234-5678-90ab-cdef-1234567890ab"} + want := []string{"vibe", "--trust", "--output", "text", "--workdir", "/work/repo", "--agent", "auto-approve", "--resume", "abcd1234-5678-90ab-cdef-1234567890ab"} if !reflect.DeepEqual(cmd, want) { t.Fatalf("cmd = %#v, want %#v", cmd, want) } diff --git a/backend/internal/cli/stop_test.go b/backend/internal/cli/stop_test.go index 0a150d633..9a298ecae 100644 --- a/backend/internal/cli/stop_test.go +++ b/backend/internal/cli/stop_test.go @@ -43,6 +43,7 @@ func TestWaitForStoppedKeepsRunFileFromConcurrentStart(t *testing.T) { } if info == nil { t.Fatal("new daemon's run-file was deleted by stop of a different PID") + return } if info.PID != newPID { t.Fatalf("run-file PID = %d, want %d (new daemon)", info.PID, newPID) diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index ab7c2b2eb..727e0a86f 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -16,11 +16,13 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/daemon/supervisor" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/notify" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/preview" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer" notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" @@ -132,7 +134,8 @@ func Run() error { previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx) srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ - Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, Telemetry: telemetrySink}), + Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}), + Agents: agentsvc.New(), Sessions: sessionSvc, Reviews: reviewSvc, Notifications: notifier, diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 3ec736911..218e6815b 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -19,6 +19,7 @@ import ( // APIDeps bundles every service the API layer's controllers depend on. type APIDeps struct { + Agents controllers.AgentCatalog Projects projectsvc.Manager Sessions controllers.SessionService Activity controllers.ActivityRecorder @@ -36,6 +37,7 @@ type APIDeps struct { // router invokes to mount the /api/v1 surface. type API struct { cfg config.Config + agents *controllers.AgentsController projects *controllers.ProjectsController sessions *controllers.SessionsController prs *controllers.PRsController @@ -51,6 +53,9 @@ type API struct { func NewAPI(cfg config.Config, deps APIDeps) *API { return &API{ cfg: cfg, + agents: &controllers.AgentsController{ + Catalog: deps.Agents, + }, projects: &controllers.ProjectsController{ Mgr: deps.Projects, }, @@ -80,6 +85,7 @@ func (a *API) Register(root chi.Router) { r.Group(func(r chi.Router) { r.Use(middleware.Timeout(timeout)) + a.agents.Register(r) a.projects.Register(r) a.sessions.Register(r) a.prs.Register(r) diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 2d7e7bf46..d3c628a46 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -8,6 +8,56 @@ servers: - description: Local daemon (loopback only) url: http://127.0.0.1:3001 paths: + /api/v1/agents: + get: + operationId: listAgents + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListAgentsResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Return cached supported and locally installed agent adapters + tags: + - agents + /api/v1/agents/refresh: + post: + operationId: refreshAgents + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListAgentsResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Refresh the cached local agent adapter catalog + tags: + - agents /api/v1/events: get: operationId: streamEvents @@ -1486,6 +1536,24 @@ components: permissions: type: string type: object + AgentInfo: + properties: + authStatus: + description: Advisory local auth probe result. authorized means a recent + local probe passed; spawn remains the authoritative validation point. + enum: + - authorized + - unauthorized + - unknown + type: string + id: + type: string + label: + type: string + required: + - id + - label + type: object ClaimPRRequest: properties: allowTakeover: @@ -1695,6 +1763,31 @@ components: - ok - sessionId type: object + ListAgentsResponse: + properties: + authorized: + description: Compatibility list of installed agents whose local auth probe + recently returned authorized. Advisory and stale-prone; spawn may still + fail. + items: + $ref: '#/components/schemas/AgentInfo' + type: array + installed: + description: Agents whose binary resolved during the latest best-effort + local catalog probe. + items: + $ref: '#/components/schemas/AgentInfo' + type: array + supported: + description: Agents supported by this daemon build. + items: + $ref: '#/components/schemas/AgentInfo' + type: array + required: + - supported + - installed + - authorized + type: object ListNotificationsResponse: properties: notifications: @@ -2587,6 +2680,8 @@ components: - repo type: object tags: +- description: Supported and locally runnable agent adapters + name: agents - description: Project registry, configuration, and lifecycle administration name: projects - description: Agent session lifecycle and messaging diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 564328684..9e3d0634a 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -55,6 +55,8 @@ func Build() ([]byte, error) { *(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"), } r.Spec.Tags = []openapi31.Tag{ + *(&openapi31.Tag{Name: "agents"}).WithDescription( + "Supported and locally runnable agent adapters"), *(&openapi31.Tag{Name: "projects"}).WithDescription( "Project registry, configuration, and lifecycle administration"), *(&openapi31.Tag{Name: "sessions"}).WithDescription( @@ -170,6 +172,8 @@ var schemaNames = map[string]string{ "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", "ControllersOrchestratorResponse": "OrchestratorResponse", + "AgentInventory": "ListAgentsResponse", + "AgentInfo": "AgentInfo", "ControllersListNotificationsQuery": "ListNotificationsQuery", "ControllersNotificationStreamQuery": "NotificationStreamQuery", "ControllersNotificationIDParam": "NotificationIDParam", @@ -279,6 +283,7 @@ type operation struct { func operations() []operation { ops := append([]operation{}, eventOperations()...) + ops = append(ops, agentOperations()...) ops = append(ops, projectOperations()...) ops = append(ops, sessionOperations()...) ops = append(ops, prOperations()...) @@ -288,6 +293,29 @@ func operations() []operation { return ops } +func agentOperations() []operation { + return []operation{ + { + method: http.MethodGet, path: "/api/v1/agents", id: "listAgents", tag: "agents", + summary: "Return cached supported and locally installed agent adapters", + resps: []respUnit{ + {http.StatusOK, controllers.ListAgentsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/agents/refresh", id: "refreshAgents", tag: "agents", + summary: "Refresh the cached local agent adapter catalog", + resps: []respUnit{ + {http.StatusOK, controllers.RefreshAgentsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + } +} + // importOperations declares the 2 /import operations. Must stay 1:1 with // the routes ImportController.Register mounts (enforced by the parity test). func importOperations() []operation { diff --git a/backend/internal/httpd/controllers/agents.go b/backend/internal/httpd/controllers/agents.go new file mode 100644 index 000000000..d8c3624e3 --- /dev/null +++ b/backend/internal/httpd/controllers/agents.go @@ -0,0 +1,55 @@ +package controllers + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" +) + +// AgentCatalog is the controller-facing contract for local agent inventory. +type AgentCatalog interface { + List(ctx context.Context) (agentsvc.Inventory, error) + Refresh(ctx context.Context) (agentsvc.Inventory, error) +} + +// AgentsController owns the /agents routes. +type AgentsController struct { + Catalog AgentCatalog +} + +// Register mounts the agent inventory routes on the supplied router. +func (c *AgentsController) Register(r chi.Router) { + r.Get("/agents", c.list) + r.Post("/agents/refresh", c.refresh) +} + +func (c *AgentsController) list(w http.ResponseWriter, r *http.Request) { + if c.Catalog == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/agents") + return + } + inventory, err := c.Catalog.List(r.Context()) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, inventory) +} + +func (c *AgentsController) refresh(w http.ResponseWriter, r *http.Request) { + if c.Catalog == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/agents/refresh") + return + } + inventory, err := c.Catalog.Refresh(r.Context()) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, inventory) +} diff --git a/backend/internal/httpd/controllers/agents_test.go b/backend/internal/httpd/controllers/agents_test.go new file mode 100644 index 000000000..76e0d0545 --- /dev/null +++ b/backend/internal/httpd/controllers/agents_test.go @@ -0,0 +1,94 @@ +package controllers_test + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" +) + +type fakeAgentCatalog struct { + inventory agentsvc.Inventory + refreshed agentsvc.Inventory + err error + listCalls int + refreshCalls int +} + +func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) { + f.listCalls++ + return f.inventory, f.err +} + +func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error) { + f.refreshCalls++ + if f.refreshed.Supported != nil { + return f.refreshed, f.err + } + return f.inventory, f.err +} + +func TestListAgents(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + catalog := &fakeAgentCatalog{inventory: agentsvc.Inventory{ + Supported: []agentsvc.Info{{ID: "claude-code", Label: "Claude Code"}, {ID: "codex", Label: "Codex"}}, + Installed: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + Authorized: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + }} + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + Agents: catalog, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodGet, "/api/v1/agents", "") + if status != http.StatusOK { + t.Fatalf("GET /agents = %d, body=%s", status, body) + } + for _, want := range []string{`"supported"`, `"installed"`, `"authorized"`, `"id":"codex"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("body missing %s: %s", want, body) + } + } + if strings.Contains(string(body), `"counts"`) { + t.Fatalf("body includes removed counts field: %s", body) + } + if catalog.listCalls != 1 || catalog.refreshCalls != 0 { + t.Fatalf("calls: list=%d refresh=%d, want list=1 refresh=0", catalog.listCalls, catalog.refreshCalls) + } +} + +func TestRefreshAgents(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + catalog := &fakeAgentCatalog{ + inventory: agentsvc.Inventory{Supported: []agentsvc.Info{{ID: "codex", Label: "Codex"}}}, + refreshed: agentsvc.Inventory{ + Supported: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + Installed: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + Authorized: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + }, + } + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + Agents: catalog, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/agents/refresh", "") + if status != http.StatusOK { + t.Fatalf("POST /agents/refresh = %d, body=%s", status, body) + } + for _, want := range []string{`"supported"`, `"installed"`, `"authorized"`, `"id":"codex"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("body missing %s: %s", want, body) + } + } + if catalog.listCalls != 0 || catalog.refreshCalls != 1 { + t.Fatalf("calls: list=%d refresh=%d, want list=0 refresh=1", catalog.listCalls, catalog.refreshCalls) + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index ab859ef7a..0f830b244 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -7,6 +7,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) @@ -435,6 +436,15 @@ type OrchestratorResponse struct { ProjectName string `json:"projectName,omitempty"` } +// ListAgentsResponse is the body of GET /api/v1/agents. +type ListAgentsResponse = agentsvc.Inventory + +// RefreshAgentsResponse is the body of POST /api/v1/agents/refresh. +type RefreshAgentsResponse = agentsvc.Inventory + +// AgentInfo is one supported or installed agent entry. +type AgentInfo = agentsvc.Info + // ListNotificationsQuery is the query string accepted by GET /api/v1/notifications. type ListNotificationsQuery struct { Status string `query:"status,omitempty" enum:"unread" description:"Notification status filter. V1 supports only unread."` diff --git a/backend/internal/httpd/server_test.go b/backend/internal/httpd/server_test.go index 299d218f6..a315c78a0 100644 --- a/backend/internal/httpd/server_test.go +++ b/backend/internal/httpd/server_test.go @@ -96,6 +96,7 @@ func TestServerLifecycle(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) + defer cancel() runErr := make(chan error, 1) go func() { runErr <- srv.Run(ctx) }() @@ -109,6 +110,7 @@ func TestServerLifecycle(t *testing.T) { } if info == nil { t.Fatal("run-file not written while server running") + return } if info.Port == 0 { t.Error("run-file recorded port 0; want the actual bound port") diff --git a/backend/internal/observe/scm/observer_test.go b/backend/internal/observe/scm/observer_test.go index f07b1fef5..d75d217a7 100644 --- a/backend/internal/observe/scm/observer_test.go +++ b/backend/internal/observe/scm/observer_test.go @@ -681,6 +681,7 @@ func TestPoll_DiscoveredPRPersistedAsBaselineBeforeRefresh(t *testing.T) { } if baseline == nil { t.Fatalf("discovered PR #1 not persisted as a baseline row; writes=%#v", store.writes) + return } if baseline.Merged || baseline.Closed { t.Fatalf("baseline row must be open, got merged=%v closed=%v", baseline.Merged, baseline.Closed) diff --git a/backend/internal/ports/agent.go b/backend/internal/ports/agent.go index 9b5d6a9e2..0b8c39dfb 100644 --- a/backend/internal/ports/agent.go +++ b/backend/internal/ports/agent.go @@ -14,6 +14,22 @@ import ( // for a live session. var ErrAgentBinaryNotFound = errors.New("agent: binary not found on PATH") +// AgentAuthStatus describes the result of a short local auth probe for an +// installed agent. It is advisory only: credentials, quota, selected model +// availability, or CLI state can still fail at session spawn/model-call time. +type AgentAuthStatus string + +const ( + // AgentAuthStatusAuthorized means the local auth probe recently passed. + // It does not guarantee that a later spawn or model call will succeed. + AgentAuthStatusAuthorized AgentAuthStatus = "authorized" + // AgentAuthStatusUnauthorized means the agent is installed but its local + // auth probe reported missing or invalid authentication. + AgentAuthStatusUnauthorized AgentAuthStatus = "unauthorized" + // AgentAuthStatusUnknown means the daemon could not determine auth status. + AgentAuthStatusUnknown AgentAuthStatus = "unknown" +) + // Agent is the contract every CLI coding agent adapter (claude-code, codex, …) // must satisfy. It supplies the argv and process configuration the Session // Manager needs to launch, restore, and read back a native agent session. @@ -42,6 +58,18 @@ type Agent interface { SessionInfo(ctx context.Context, session SessionRef) (info SessionInfo, ok bool, err error) } +// AgentAuthChecker is the optional capability for adapters whose native CLI has +// a cheap local authentication status probe. +type AgentAuthChecker interface { + AuthStatus(ctx context.Context) (AgentAuthStatus, error) +} + +// AgentBinaryResolver is the optional capability adapters expose when their +// binary can be checked without constructing a real session launch command. +type AgentBinaryResolver interface { + ResolveBinary(ctx context.Context) (path string, err error) +} + // AgentResolver maps a session's harness onto the Agent adapter that drives it, // so the Session Manager can spawn (and restore) a different agent per session // without depending on the concrete adapter registry. ok=false means no adapter diff --git a/backend/internal/runfile/runfile_test.go b/backend/internal/runfile/runfile_test.go index 87b62b320..162421e40 100644 --- a/backend/internal/runfile/runfile_test.go +++ b/backend/internal/runfile/runfile_test.go @@ -20,6 +20,7 @@ func TestWriteReadRoundTrip(t *testing.T) { } if got == nil { t.Fatal("Read returned nil for an existing file") + return } if got.PID != want.PID || got.Port != want.Port || !got.StartedAt.Equal(want.StartedAt) { t.Errorf("round trip mismatch: got %+v, want %+v", *got, want) @@ -44,6 +45,7 @@ func TestWriteReadRoundTripOwner(t *testing.T) { } if got == nil { t.Fatal("Read returned nil for an existing file") + return } if got.Owner != "app" { t.Errorf("Owner round trip: got %q, want %q", got.Owner, "app") @@ -60,6 +62,7 @@ func TestWriteReadRoundTripOwner(t *testing.T) { } if got == nil { t.Fatal("Read returned nil for headless file") + return } if got.Owner != "" { t.Errorf("headless Owner round trip: got %q, want %q", got.Owner, "") @@ -164,6 +167,7 @@ func TestCheckStaleLivePID(t *testing.T) { } if live == nil { t.Fatal("CheckStale on live PID = nil, want the live Info") + return } if live.PID != os.Getpid() { t.Errorf("live.PID = %d, want %d", live.PID, os.Getpid()) diff --git a/backend/internal/service/agent/catalog_test.go b/backend/internal/service/agent/catalog_test.go new file mode 100644 index 000000000..2df062e3c --- /dev/null +++ b/backend/internal/service/agent/catalog_test.go @@ -0,0 +1,303 @@ +package agent + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type fakeAgent struct { + err error + delay time.Duration +} + +type fakeAuthAgent struct { + fakeAgent + status ports.AgentAuthStatus + authErr error + authDelay time.Duration +} + +type probeTrackingAgent struct { + fakeAgent + onProbe func() +} + +func (f fakeAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, nil +} + +func (f fakeAgent) GetLaunchCommand(ctx context.Context, _ ports.LaunchConfig) ([]string, error) { + if f.delay > 0 { + select { + case <-time.After(f.delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if f.err != nil { + return nil, f.err + } + return []string{"agent"}, nil +} + +func (f fakeAgent) ResolveBinary(ctx context.Context) (string, error) { + if f.delay > 0 { + select { + case <-time.After(f.delay): + case <-ctx.Done(): + return "", ctx.Err() + } + } + if f.err != nil { + return "", f.err + } + return "agent", nil +} + +func (f probeTrackingAgent) ResolveBinary(ctx context.Context) (string, error) { + if f.onProbe != nil { + f.onProbe() + } + return f.fakeAgent.ResolveBinary(ctx) +} + +func (f fakeAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + return ports.PromptDeliveryInCommand, nil +} + +func (f fakeAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { + return nil +} + +func (f fakeAgent) GetRestoreCommand(context.Context, ports.RestoreConfig) ([]string, bool, error) { + return nil, false, nil +} + +func (f fakeAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) { + return ports.SessionInfo{}, false, nil +} + +func (f fakeAuthAgent) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if f.authDelay > 0 { + select { + case <-time.After(f.authDelay): + case <-ctx.Done(): + return ports.AgentAuthStatusUnknown, ctx.Err() + } + } + return f.status, f.authErr +} + +func TestListReturnsInitialSupportedInventoryWithoutProbing(t *testing.T) { + probed := false + svc := NewWithAgents([]agentregistry.HarnessAgent{ + { + Harness: domain.AgentHarness("codex"), + Manifest: adapters.Manifest{ + ID: "codex", + Name: "Codex", + }, + Agent: probeTrackingAgent{onProbe: func() { probed = true }}, + }, + }) + + got, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if probed { + t.Fatal("List ran a live probe") + } + if len(got.Supported) != 1 || got.Supported[0].ID != "codex" { + t.Fatalf("supported = %#v, want codex", got.Supported) + } + if len(got.Installed) != 0 || len(got.Authorized) != 0 { + t.Fatalf("inventory = %#v, want only supported entries before refresh", got) + } + if got.Installed == nil { + t.Fatal("Installed = nil, want empty slice") + } + if got.Authorized == nil { + t.Fatal("Authorized = nil, want empty slice") + } +} + +func TestRefreshReportsInstalledAgentsAndIgnoresDetectorErrors(t *testing.T) { + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAgent("codex", "Codex", nil), + harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound), + harnessAgent("broken", "Broken", errors.New("unexpected detector failure")), + }) + + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if len(got.Supported) != 3 { + t.Fatalf("supported = %#v, want 3 agents", got.Supported) + } + if len(got.Installed) != 1 || got.Installed[0].ID != "codex" { + t.Fatalf("installed = %#v, want only codex", got.Installed) + } +} + +func TestRefreshReportsAuthorizedInstalledAgents(t *testing.T) { + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAuthAgent("codex", "Codex", ports.AgentAuthStatusAuthorized, nil), + harnessAuthAgent("claude-code", "Claude Code", ports.AgentAuthStatusUnauthorized, nil), + harnessAgent("opencode", "OpenCode", nil), + harnessAuthAgent("broken-auth", "Broken Auth", ports.AgentAuthStatusAuthorized, errors.New("probe failed")), + }) + + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if len(got.Supported) != 4 || len(got.Installed) != 4 { + t.Fatalf("inventory = %#v, want supported=4 installed=4", got) + } + if len(got.Authorized) != 1 || got.Authorized[0].ID != "codex" { + t.Fatalf("authorized = %#v, want only codex", got.Authorized) + } + + byID := map[string]Info{} + for _, info := range got.Installed { + byID[info.ID] = info + } + if byID["codex"].AuthStatus != ports.AgentAuthStatusAuthorized { + t.Fatalf("codex authStatus = %q", byID["codex"].AuthStatus) + } + if byID["claude-code"].AuthStatus != ports.AgentAuthStatusUnauthorized { + t.Fatalf("claude-code authStatus = %q", byID["claude-code"].AuthStatus) + } + if byID["opencode"].AuthStatus != ports.AgentAuthStatusUnknown { + t.Fatalf("opencode authStatus = %q", byID["opencode"].AuthStatus) + } + if byID["broken-auth"].AuthStatus != ports.AgentAuthStatusUnknown { + t.Fatalf("broken-auth authStatus = %q", byID["broken-auth"].AuthStatus) + } +} + +func TestRefreshDoesNotWaitForSlowAgentProbe(t *testing.T) { + previous := agentInstallProbeTimeout + agentInstallProbeTimeout = 20 * time.Millisecond + t.Cleanup(func() { agentInstallProbeTimeout = previous }) + + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAgent("codex", "Codex", nil), + { + Harness: domain.AgentHarness("slow"), + Manifest: adapters.Manifest{ + ID: "slow", + Name: "Slow", + }, + Agent: fakeAgent{delay: time.Minute}, + }, + }) + + start := time.Now() + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if elapsed := time.Since(start); elapsed > 500*time.Millisecond { + t.Fatalf("List took %s, want bounded by slow probe timeout", elapsed) + } + if len(got.Supported) != 2 { + t.Fatalf("supported = %#v, want both agents", got.Supported) + } + if len(got.Installed) != 1 || got.Installed[0].ID != "codex" { + t.Fatalf("installed = %#v, want only codex", got.Installed) + } +} + +func TestRefreshUsesSeparateTimeoutForAuthProbe(t *testing.T) { + previousInstall := agentInstallProbeTimeout + previousAuth := agentAuthProbeTimeout + agentInstallProbeTimeout = 20 * time.Millisecond + agentAuthProbeTimeout = 200 * time.Millisecond + t.Cleanup(func() { + agentInstallProbeTimeout = previousInstall + agentAuthProbeTimeout = previousAuth + }) + + svc := NewWithAgents([]agentregistry.HarnessAgent{ + { + Harness: domain.AgentHarness("claude-code"), + Manifest: adapters.Manifest{ + ID: "claude-code", + Name: "Claude Code", + }, + Agent: fakeAuthAgent{ + fakeAgent: fakeAgent{}, + status: ports.AgentAuthStatusAuthorized, + authDelay: 75 * time.Millisecond, + }, + }, + }) + + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if len(got.Authorized) != 1 || got.Authorized[0].ID != "claude-code" { + t.Fatalf("authorized = %#v, want claude-code", got.Authorized) + } +} + +func TestRefreshIsRateLimited(t *testing.T) { + previous := agentRefreshMinInterval + agentRefreshMinInterval = time.Hour + t.Cleanup(func() { agentRefreshMinInterval = previous }) + + probes := 0 + svc := NewWithAgents([]agentregistry.HarnessAgent{ + { + Harness: domain.AgentHarness("codex"), + Manifest: adapters.Manifest{ + ID: "codex", + Name: "Codex", + }, + Agent: probeTrackingAgent{onProbe: func() { probes++ }}, + }, + }) + + if _, err := svc.Refresh(context.Background()); err != nil { + t.Fatalf("first Refresh: %v", err) + } + if _, err := svc.Refresh(context.Background()); err != nil { + t.Fatalf("second Refresh: %v", err) + } + if probes != 1 { + t.Fatalf("probes = %d, want 1", probes) + } +} + +func harnessAgent(id, label string, err error) agentregistry.HarnessAgent { + return agentregistry.HarnessAgent{ + Harness: domain.AgentHarness(id), + Manifest: adapters.Manifest{ + ID: id, + Name: label, + }, + Agent: fakeAgent{err: err}, + } +} + +func harnessAuthAgent(id, label string, status ports.AgentAuthStatus, err error) agentregistry.HarnessAgent { + return agentregistry.HarnessAgent{ + Harness: domain.AgentHarness(id), + Manifest: adapters.Manifest{ + ID: id, + Name: label, + }, + Agent: fakeAuthAgent{fakeAgent: fakeAgent{}, status: status, authErr: err}, + } +} diff --git a/backend/internal/service/agent/service.go b/backend/internal/service/agent/service.go new file mode 100644 index 000000000..2a2391294 --- /dev/null +++ b/backend/internal/service/agent/service.go @@ -0,0 +1,214 @@ +package agent + +import ( + "context" + "errors" + "sort" + "sync" + "time" + + agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var ( + agentInstallProbeTimeout = 2 * time.Second + agentAuthProbeTimeout = 10 * time.Second + agentRefreshMinInterval = 10 * time.Second +) + +type probeResult struct { + info Info + installed bool + authorized bool +} + +// Info is the user-facing identity for an agent adapter. +type Info struct { + ID string `json:"id"` + Label string `json:"label"` + AuthStatus ports.AgentAuthStatus `json:"authStatus,omitempty" enum:"authorized,unauthorized,unknown" description:"Advisory local auth probe result. authorized means a recent local probe passed; spawn remains the authoritative validation point."` +} + +// Inventory describes all daemon-supported agents and best-effort local probe +// results. Installed/authorized entries are advisory snapshots and can be stale; +// session spawn is the authoritative validation point for binary availability, +// runtime prerequisites, and model-call readiness. +type Inventory struct { + Supported []Info `json:"supported" description:"Agents supported by this daemon build."` + Installed []Info `json:"installed" description:"Agents whose binary resolved during the latest best-effort local catalog probe."` + Authorized []Info `json:"authorized" description:"Compatibility list of installed agents whose local auth probe recently returned authorized. Advisory and stale-prone; spawn may still fail."` +} + +// Service reports supported agent adapters and best-effort local readiness +// probes. Catalog readiness is advisory UI metadata, not a spawn precheck. +type Service struct { + agents []agentregistry.HarnessAgent + + mu sync.RWMutex + inventory Inventory + lastRefresh time.Time + refreshMu sync.Mutex +} + +// New returns an agent inventory service backed by the daemon's shipped +// adapter registry. +func New() *Service { + return NewWithAgents(agentregistry.Harnessed()) +} + +// NewWithAgents returns an inventory service over a caller-provided adapter +// slice. It is used by focused tests. +func NewWithAgents(agents []agentregistry.HarnessAgent) *Service { + return &Service{agents: agents, inventory: Inventory{ + Supported: supportedInfos(agents), + Installed: []Info{}, + Authorized: []Info{}, + }} +} + +// List returns the cached agent inventory without running probes. Installed and +// authorized entries come from the last explicit Refresh call and are advisory: +// they can be stale by the time a user starts a session, and session spawn +// performs the authoritative binary/runtime validation. +func (s *Service) List(ctx context.Context) (Inventory, error) { + if err := ctx.Err(); err != nil { + return Inventory{}, err + } + s.mu.RLock() + defer s.mu.RUnlock() + return cloneInventory(s.inventory), nil +} + +// Refresh runs the bounded local binary/auth probes, updates the cached +// inventory, and returns the new snapshot. Refreshes are serialized and +// rate-limited so repeated frontend reloads cannot stampede agent CLIs. +func (s *Service) Refresh(ctx context.Context) (Inventory, error) { + if err := ctx.Err(); err != nil { + return Inventory{}, err + } + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + + s.mu.RLock() + if !s.lastRefresh.IsZero() && time.Since(s.lastRefresh) < agentRefreshMinInterval { + cached := cloneInventory(s.inventory) + s.mu.RUnlock() + return cached, nil + } + s.mu.RUnlock() + + results := make(chan probeResult, len(s.agents)) + var wg sync.WaitGroup + for _, item := range s.agents { + if err := ctx.Err(); err != nil { + return Inventory{}, err + } + wg.Add(1) + go func(item agentregistry.HarnessAgent) { + defer wg.Done() + results <- probeAgent(ctx, item) + }(item) + } + wg.Wait() + close(results) + + supported := make([]Info, 0, len(s.agents)) + installed := make([]Info, 0, len(s.agents)) + authorized := make([]Info, 0, len(s.agents)) + for res := range results { + supported = append(supported, res.info) + if res.installed { + installed = append(installed, res.info) + } + if res.authorized { + authorized = append(authorized, res.info) + } + } + sortInfos(supported) + sortInfos(installed) + sortInfos(authorized) + next := Inventory{ + Supported: supported, + Installed: installed, + Authorized: authorized, + } + s.mu.Lock() + s.inventory = cloneInventory(next) + s.lastRefresh = time.Now() + s.mu.Unlock() + return next, nil +} + +func supportedInfos(agents []agentregistry.HarnessAgent) []Info { + supported := make([]Info, 0, len(agents)) + for _, item := range agents { + info := Info{ID: string(item.Harness), Label: item.Manifest.Name} + if info.Label == "" { + info.Label = info.ID + } + supported = append(supported, info) + } + sortInfos(supported) + return supported +} + +func cloneInventory(in Inventory) Inventory { + return Inventory{ + Supported: cloneInfos(in.Supported), + Installed: cloneInfos(in.Installed), + Authorized: cloneInfos(in.Authorized), + } +} + +func cloneInfos(in []Info) []Info { + out := make([]Info, len(in)) + copy(out, in) + return out +} + +func probeAgent(ctx context.Context, item agentregistry.HarnessAgent) probeResult { + info := Info{ID: string(item.Harness), Label: item.Manifest.Name} + if info.Label == "" { + info.Label = info.ID + } + probeCtx, cancel := context.WithTimeout(ctx, agentInstallProbeTimeout) + defer cancel() + resolver, ok := item.Agent.(ports.AgentBinaryResolver) + if !ok { + return probeResult{info: info} + } + if _, err := resolver.ResolveBinary(probeCtx); err != nil { + return probeResult{info: info} + } + authCtx, authCancel := context.WithTimeout(ctx, agentAuthProbeTimeout) + defer authCancel() + info.AuthStatus = authStatus(authCtx, item.Agent) + return probeResult{info: info, installed: true, authorized: info.AuthStatus == ports.AgentAuthStatusAuthorized} +} + +func authStatus(ctx context.Context, a ports.Agent) ports.AgentAuthStatus { + checker, ok := a.(ports.AgentAuthChecker) + if !ok { + return ports.AgentAuthStatusUnknown + } + status, err := checker.AuthStatus(ctx) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return ports.AgentAuthStatusUnknown + } + return ports.AgentAuthStatusUnknown + } + switch status { + case ports.AgentAuthStatusAuthorized, ports.AgentAuthStatusUnauthorized: + return status + default: + return ports.AgentAuthStatusUnknown + } +} + +func sortInfos(infos []Info) { + sort.Slice(infos, func(i, j int) bool { + return infos[i].ID < infos[j].ID + }) +} diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index af8593737..c8d2e3d65 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -45,10 +46,11 @@ type SessionTeardowner interface { // Service implements project registration and lookup use-cases for controllers. type Service struct { - store Store - sessions SessionTeardowner - clock func() time.Time - telemetry ports.EventSink + store Store + sessions SessionTeardowner + clock func() time.Time + telemetry ports.EventSink + defaultHarness domain.AgentHarness // addMu serialises the whole body of Add. Workspace registration performs // filesystem mutations (git init, .gitignore writes, commits) that are not // covered by the store's own writeMu, so path/id conflict checks plus the @@ -60,10 +62,13 @@ var _ Manager = (*Service)(nil) // Deps captures optional collaborators for project use-cases. type Deps struct { - Store Store - Sessions SessionTeardowner - Clock func() time.Time - Telemetry ports.EventSink + // DefaultHarness is the daemon's configured default agent (AO_AGENT). + // When empty, the service falls back to config.DefaultAgent. + DefaultHarness domain.AgentHarness + Store Store + Sessions SessionTeardowner + Clock func() time.Time + Telemetry ports.EventSink } // New returns a project service backed by the given durable store. @@ -73,7 +78,17 @@ func New(store Store) *Service { // NewWithDeps returns a project service with optional teardown dependencies. func NewWithDeps(d Deps) *Service { - s := &Service{store: d.Store, sessions: d.Sessions, clock: d.Clock, telemetry: d.Telemetry} + defaultHarness := d.DefaultHarness + if defaultHarness == "" { + defaultHarness = domain.AgentHarness(config.DefaultAgent) + } + s := &Service{ + store: d.Store, + sessions: d.Sessions, + clock: d.Clock, + telemetry: d.Telemetry, + defaultHarness: defaultHarness, + } if s.clock == nil { s.clock = time.Now } @@ -111,7 +126,7 @@ func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, erro if !ok || !row.ArchivedAt.IsZero() { return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") } - p := projectFromRow(row) + p := m.projectFromRow(row) if row.Kind.WithDefault() == domain.ProjectKindWorkspace { repos, err := m.store.ListWorkspaceRepos(ctx, row.ID) if err != nil { @@ -174,12 +189,12 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { }) } - var config domain.ProjectConfig + var projectConfig domain.ProjectConfig if in.Config != nil { if err := in.Config.Validate(); err != nil { return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil) } - config = *in.Config + projectConfig = *in.Config } registeredAt := time.Now() @@ -189,7 +204,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { DisplayName: name, RegisteredAt: registeredAt, Kind: domain.ProjectKindSingleRepo, - Config: config, + Config: projectConfig, } if in.AsWorkspace { repos, err := prepareWorkspaceProject(ctx, path, domain.ProjectID(row.ID), registeredAt) @@ -202,7 +217,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register workspace project") } m.emitProjectAdded(row, projectCountBefore == 0) - p := projectFromRow(row) + p := m.projectFromRow(row) p.WorkspaceRepos = workspaceReposFromRecords(repos) return p, nil } @@ -224,7 +239,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") } m.emitProjectAdded(row, projectCountBefore == 0) - return projectFromRow(row), nil + return m.projectFromRow(row), nil } func (m *Service) activeProjectCount(ctx context.Context) (int, error) { @@ -286,7 +301,7 @@ func (m *Service) SetConfig(ctx context.Context, id domain.ProjectID, in SetConf if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, apierr.Internal("PROJECT_CONFIG_UPDATE_FAILED", "Failed to update project config") } - return projectFromRow(row), nil + return m.projectFromRow(row), nil } // resolveGitOriginURL returns the project's `origin` remote URL via @@ -365,7 +380,7 @@ func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.P } } -func projectFromRow(row domain.ProjectRecord) Project { +func (m *Service) projectFromRow(row domain.ProjectRecord) Project { p := Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), @@ -373,6 +388,7 @@ func projectFromRow(row domain.ProjectRecord) Project { Path: row.Path, Repo: row.RepoOriginURL, DefaultBranch: row.Config.WithDefaults().DefaultBranch, + Agent: string(m.defaultHarness), } if !row.Config.IsZero() { cfg := row.Config diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 4901b83bd..f10dba67e 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -272,6 +272,9 @@ func TestManager_DefaultsWhenUnconfigured(t *testing.T) { if got.Project.DefaultBranch != domain.DefaultBranchName { t.Fatalf("default branch = %q, want %q", got.Project.DefaultBranch, domain.DefaultBranchName) } + if got.Project.Agent != "claude-code" { + t.Fatalf("default agent = %q, want claude-code", got.Project.Agent) + } if got.Project.Config != nil { t.Fatalf("unconfigured project should omit config, got %#v", got.Project.Config) } @@ -285,6 +288,32 @@ func TestManager_DefaultsWhenUnconfigured(t *testing.T) { } } +func TestManager_GetUsesConfiguredDefaultHarness(t *testing.T) { + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + m := project.NewWithDeps(project.Deps{Store: store, DefaultHarness: domain.HarnessCodex}) + repo := gitRepo(t) + + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil { + t.Fatalf("Add: %v", err) + } + + got, err := m.Get(ctx, "ao") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Project == nil { + t.Fatalf("Get returned no project: %#v", got) + } + if got.Project.Agent != "codex" { + t.Fatalf("default agent = %q, want codex", got.Project.Agent) + } +} + func TestManager_AddDetectsNonMainDefaultBranch(t *testing.T) { ctx := context.Background() m := newManager(t) diff --git a/backend/internal/service/session/claim_pr.go b/backend/internal/service/session/claim_pr.go index 54070e1dc..a0818102f 100644 --- a/backend/internal/service/session/claim_pr.go +++ b/backend/internal/service/session/claim_pr.go @@ -47,9 +47,11 @@ type ClaimPRResult struct { // ListPRs returns all PRs currently owned by a session, ordered for display. func (s *Service) ListPRs(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error) { - if _, ok, err := s.store.GetSession(ctx, id); err != nil { + _, ok, err := s.store.GetSession(ctx, id) + if err != nil { return nil, fmt.Errorf("get %s: %w", id, err) - } else if !ok { + } + if !ok { return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } return s.listPRFacts(ctx, id) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 4a808c24a..9fc3af565 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -97,7 +97,9 @@ type Store interface { // presence of any row is the marker; preserved_ref may be empty for clean // worktrees. ListSessionWorktrees(ctx context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) - // DeleteSessionWorktrees clears the "shutdown-saved" restore marker. + // DeleteSessionWorktrees consumes stale shutdown-restore markers. Explicit + // Kill and successful RestoreAll must remove these rows to prevent + // resurrecting sessions the user intentionally terminated. DeleteSessionWorktrees(ctx context.Context, id domain.SessionID) error } @@ -450,11 +452,8 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { if err := m.lcm.MarkTerminated(ctx, id); err != nil { return false, fmt.Errorf("kill %s: %w", id, err) } - - // Clear the restore marker so the next boot's RestoreAll cannot resurrect a - // killed session (#2319). Best-effort: teardown below still matters. if err := m.store.DeleteSessionWorktrees(ctx, id); err != nil { - m.logger.Warn("kill: delete restore marker failed", "sessionID", id, "error", err) + return false, fmt.Errorf("kill %s: delete restore marker: %w", id, err) } // Only tear down what exists. A session may have lost its handle after a @@ -849,12 +848,10 @@ func (m *Manager) RestoreAll(ctx context.Context) error { } else { m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err) } + continue } - - // One-shot: drop the consumed marker so it never outlives one restart - // (#2319). A still-live session re-acquires it at the next quit. if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil { - m.logger.Warn("restore-all: delete restore marker failed", "sessionID", rec.ID, "error", err) + m.logger.Error("restore-all: delete consumed worktree marker failed", "sessionID", rec.ID, "error", err) } } return nil diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 261881cd0..679f7a6e5 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -564,6 +564,25 @@ func TestKill_DirtyWorkspaceTerminatesAndPreserves(t *testing.T) { } } +func TestKill_DeletesStaleRestoreMarker(t *testing.T) { + m, st, _, _ := newManager() + st.sessions["mer-1"] = mkLive("mer-1") + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, WorktreePath: "/tmp/wt"}, + } + + freed, err := m.Kill(ctx, "mer-1") + if err != nil { + t.Fatalf("Kill: %v", err) + } + if !freed { + t.Fatal("Kill freed = false, want true") + } + if rows := st.worktrees["mer-1"]; len(rows) != 0 { + t.Fatalf("stale restore marker = %+v, want deleted", rows) + } +} + // TestKill_OtherWorkspaceErrorStillFails: only the typed dirty refusal is a // success-with-preserved-workspace; any other teardown failure keeps erroring. func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) { @@ -574,29 +593,6 @@ func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) { t.Fatalf("kill err = %v, want workspace error surfaced", err) } } - -// TestKill_DeletesRestoreMarker covers issue #2319 (a): a user kill is explicit -// terminal intent and must delete the session_worktrees "shutdown-saved" marker. -// A session that carried a marker (e.g. it survived a prior reopen cycle) and is -// then killed must not keep that marker, or the next boot's RestoreAll would -// resurrect it. -func TestKill_DeletesRestoreMarker(t *testing.T) { - m, st, _, _ := newManager() - st.sessions["mer-1"] = mkLive("mer-1") - // The session carries a leftover shutdown-saved marker from a prior cycle. - st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} - - if _, err := m.Kill(ctx, "mer-1"); err != nil { - t.Fatalf("kill err = %v", err) - } - rows, err := st.ListSessionWorktrees(ctx, "mer-1") - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("kill must delete the restore marker, got %d rows", len(rows)) - } -} func TestRestore_ReopensTerminal(t *testing.T) { m, st, rt, _ := newManager() seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}) @@ -1580,6 +1576,33 @@ func TestRestoreAll_RestoresBothWorkerAndOrchestrator(t *testing.T) { } } +func TestRestoreAll_ConsumesMarkersAfterSuccessfulRestore(t *testing.T) { + m, st, rt, _ := newLifecycleManager() + + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, WorktreePath: "/ws/mer-1"}, + } + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v", err) + } + if rt.created != 1 { + t.Fatalf("RestoreAll must relaunch session, runtime.Create called %d times", rt.created) + } + if rows := st.worktrees["mer-1"]; len(rows) != 0 { + t.Fatalf("consumed restore marker = %+v, want deleted", rows) + } +} + // TestRestoreAll_SkipsSessionsKilledBeforeShutdown verifies (c): a session // the user killed BEFORE shutdown has no session_worktrees row and must NOT // be resurrected. @@ -1611,81 +1634,6 @@ func TestRestoreAll_SkipsSessionsKilledBeforeShutdown(t *testing.T) { } } -// TestRestoreAll_DeletesMarkerAfterRelaunch covers issue #2319 (b): the -// shutdown-saved marker is one-shot. After RestoreAll relaunches a session, its -// session_worktrees marker is deleted, so a second RestoreAll (with no fresh -// marker) does NOT relaunch it again. -func TestRestoreAll_DeletesMarkerAfterRelaunch(t *testing.T) { - m, st, rt, _ := newLifecycleManager() - - st.sessions["mer-1"] = domain.SessionRecord{ - ID: "mer-1", - ProjectID: "mer", - Kind: domain.KindWorker, - Harness: domain.HarnessClaudeCode, - IsTerminated: true, - Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, - Activity: domain.Activity{State: domain.ActivityExited}, - } - st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} - - if err := m.RestoreAll(ctx); err != nil { - t.Fatalf("RestoreAll err = %v", err) - } - if rt.created != 1 { - t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created) - } - rows, err := st.ListSessionWorktrees(ctx, "mer-1") - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("RestoreAll must delete the one-shot marker, got %d rows", len(rows)) - } -} - -// TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot covers issue #2319 (c), -// the killed-session-resurrection scenario. A terminated session WITH a marker -// is relaunched exactly once; on a second RestoreAll (no new marker) it stays -// terminated and is not relaunched again. -func TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot(t *testing.T) { - m, st, rt, _ := newLifecycleManager() - - st.sessions["mer-1"] = domain.SessionRecord{ - ID: "mer-1", - ProjectID: "mer", - Kind: domain.KindWorker, - Harness: domain.HarnessClaudeCode, - IsTerminated: true, - Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, - Activity: domain.Activity{State: domain.ActivityExited}, - } - st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} - - // First boot: marker present, session relaunches once. - if err := m.RestoreAll(ctx); err != nil { - t.Fatalf("first RestoreAll err = %v", err) - } - if rt.created != 1 { - t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created) - } - - // Simulate the user killing the relaunched session before the next quit, so - // it has no fresh marker, then a second boot. - if _, err := m.Kill(ctx, "mer-1"); err != nil { - t.Fatalf("kill err = %v", err) - } - if err := m.RestoreAll(ctx); err != nil { - t.Fatalf("second RestoreAll err = %v", err) - } - if rt.created != 1 { - t.Fatalf("killed session must NOT be resurrected on second boot, runtime.Create total = %d, want 1", rt.created) - } - if !st.sessions["mer-1"].IsTerminated { - t.Error("killed session must remain terminated after second RestoreAll") - } -} - // TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a // non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace // restore but before relaunching. diff --git a/docs/README.md b/docs/README.md index 073951ec3..1ecef2359 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,6 @@ Start with [architecture.md](architecture.md) for the current backend model and | [architecture.md](architecture.md) | Current backend model, package layout, status derivation, persistence/CDC, and load-bearing rules. | | [backend-code-structure.md](backend-code-structure.md) | Package ownership rules for the Go backend: domain, services, ports, adapters, storage, HTTP, CLI, and daemon wiring. | | [cli/README.md](cli/README.md) | CLI commands and daemon control surface. | -| [agent/README.md](agent/README.md) | Agent adapter contract, hook methodology, and session-info derivation. | | [STATUS.md](STATUS.md) | What is shipped on `main` today and what is still in flight. | | [stack.md](stack.md) | Accepted library/runtime choices, pending stack decisions, and dependencies explicitly avoided for V1. | diff --git a/docs/agent/README.md b/docs/agent/README.md deleted file mode 100644 index eca64de39..000000000 --- a/docs/agent/README.md +++ /dev/null @@ -1,950 +0,0 @@ -# Agent Adapter Contract - -This document defines the contract between Agent Orchestrator and CLI-based coding agent adapters. Every agent must implement the contract defined in `backend/internal/ports/agent.go` to be usable by AO. - -## Table of Contents - -- [Overview](#overview) -- [Agent Contract](#agent-contract) -- [Hook System](#hook-system) -- [Session Metadata](#session-metadata) -- [Agent Lifecycle](#agent-lifecycle) -- [Implementation Guide](#implementation-guide) -- [Supported Agents](#supported-agents) -- [Testing](#testing) - ---- - -## Overview - -Agent adapters let AO run and observe different CLI coding agents without hardcoding agent-specific behavior into the spawn engine. The adapter contract provides: - -- **Agent discovery** — Find and validate the agent binary -- **Launch configuration** — Build the native agent command -- **Session restoration** — Resume existing sessions -- **Hook integration** — Install and manage agent hooks -- **Metadata extraction** — Surface session title, summary, and native session ID - -```mermaid -graph TB - AO[Agent Orchestrator] -->|uses| Adapter[Agent Adapter] - Adapter -->|implements| Contract[ports.Agent Contract] - - Contract --> Methods[Required Methods] - Methods --> GetConfig[GetConfigSpec] - Methods --> GetLaunch[GetLaunchCommand] - Methods --> GetStrategy[GetPromptDeliveryStrategy] - Methods --> GetHooks[GetAgentHooks] - Methods --> GetRestore[GetRestoreCommand] - Methods --> SessionInfo[SessionInfo] - Methods --> Uninstall[UninstallHooks] - - Adapter --> Agent[Native CLI Agent] - Adapter --> Hooks[Agent Hook Config] - -``` - ---- - -## Agent Contract - -### Port Interface - -The `ports.Agent` interface (defined in `backend/internal/ports/agent.go`) specifies the required adapter methods: - -```go -// Agent is the interface all coding agent adapters must implement. -type Agent interface { - // GetConfigSpec describes user-facing agent configuration. - GetConfigSpec() ConfigSpec - - // GetLaunchCommand builds the native agent command for spawning a new session. - GetLaunchCommand(ctx context.Context, cfg LaunchConfig) ([]string, error) - - // GetPromptDeliveryStrategy reports how the prompt is delivered. - GetPromptDeliveryStrategy() PromptDeliveryStrategy - - // GetAgentHooks installs or merges AO hooks into the agent's workspace-local config. - GetAgentHooks(ctx context.Context, cfg WorkspaceHookConfig) error - - // GetRestoreCommand builds a command to resume an existing session. - GetRestoreCommand(ctx context.Context, cfg RestoreConfig) ([]string, bool, error) - - // SessionInfo returns normalized session metadata from the session record. - SessionInfo(ctx context.Context, session SessionRef) (SessionInfo, bool, error) - - // UninstallHooks removes AO hooks from the workspace. - UninstallHooks(ctx context.Context, workspacePath string) error -} -``` - -### Method Details - -#### GetConfigSpec() - -Returns a specification of user-facing configuration options: - -```go -type ConfigSpec struct { - // Permissions is the permission mode the agent supports. - Permissions PermissionMode - - // Model is the optional model identifier (e.g., "claude-3-5-sonnet-20241022"). - Model string - - // SupportsSystemPrompt indicates the agent accepts a system prompt. - SupportsSystemPrompt bool - - // SupportsRestore indicates the agent supports session restoration. - SupportsRestore bool -} -``` - -#### GetLaunchCommand() - -Builds the native agent launch command: - -```mermaid -flowchart LR - Input[LaunchConfig] --> Validate[Validate Config] - Validate --> Resolve[Resolve Binary] - Resolve --> Build[Build Command] - Build --> Apply[Apply Permissions] - Apply --> Model[Add Model Flag] - Model --> System[Add System Prompt] - System --> Prompt[Add Prompt] - Prompt --> Output[Launch Command] - -``` - -**Input (`LaunchConfig`):** - -- `SessionID` — AO session identifier -- `ProjectID` — AO project identifier -- `Prompt` — User prompt to send -- `Config` — Agent-specific configuration -- `Permissions` — Permission mode override -- `WorkspacePath` — Path to session worktree - -**Output:** Executable command line as argv array - -#### GetPromptDeliveryStrategy() - -Reports how the prompt is delivered to the agent: - -```go -type PromptDeliveryStrategy string - -const ( - // StrategyArgv means the prompt is passed as a command-line argument. - StrategyArgv PromptDeliveryStrategy = "argv" - - // StrategyStdin means the prompt is written to stdin after launch. - StrategyStdin PromptDeliveryStrategy = "stdin" -) -``` - -#### GetAgentHooks() - -Installs AO hooks into the agent's workspace-local configuration: - -```mermaid -flowchart TD - Input[WorkspaceHookConfig] --> Read[Read Existing Config] - Read --> Merge[Merge AO Hooks] - Merge --> Dedup[Deduplicate Entries] - Dedupe --> Preserve[Preserve User Hooks] - Preserve --> Write[Write Updated Config] - Write --> Gitignore[Update .gitignore] - -``` - -**Requirements:** - -- Preserve all user hooks -- Deduplicate AO hook entries -- Make AO hooks machine-portable (use `ao hooks ...` command) -- Ensure all written files are covered by `.gitignore` - -#### GetRestoreCommand() - -Builds a command to resume an existing session: - -**Input (`RestoreConfig`):** - -- `Session` — Complete session record with metadata -- `Permissions` — Permission mode to apply -- `SystemPrompt` — System prompt to re-apply - -**Output:** - -- `cmd` — Restore command argv -- `ok` — True if restore is supported -- `err` — Error if restore fails - -#### SessionInfo() - -Returns normalized session metadata from the stored session record: - -```go -type SessionInfo struct { - // AgentSessionID is the native agent's session identifier. - AgentSessionID string - - // Title is the display title derived from the first user prompt. - Title string - - // Summary is the display summary derived from the final assistant message. - Summary string - - // Metadata contains adapter-specific extra fields. - Metadata map[string]any -} -``` - -**Important:** `SessionInfo` must read from the `session.Metadata` map (populated by hooks), not from scanning agent transcript/cache files. - -#### UninstallHooks() - -Removes AO hooks from the workspace while preserving user hooks. - ---- - -## Hook System - -### Hook Overview - -AO uses agent hooks to receive activity signals and session metadata from running agents: - -```mermaid -sequenceDiagram - participant AO as AO Daemon - participant Adapter as Agent Adapter - participant Agent as Native Agent - participant Hook as Hook Command - - Note over AO: Spawning session - AO->>Adapter: GetAgentHooks(config) - Adapter->>Hook: Write hook config - Hook-->>Adapter: Config written - - AO->>Agent: Launch agent - Agent->>Agent: Running in worktree - - Note over Agent: Agent triggers event - Agent->>Hook: Execute hook callback - Hook->>Hook: Read stdin payload - Hook->>AO: POST /api/v1/sessions/{id}/activity - AO->>AO: Update activity_state - AO->>Hook: 200 OK - Hook-->>Agent: Exit 0 - - Agent->>Agent: Continue work -``` - -### Hook Events - -AO supports hooks for these agent events: - -| Event | Purpose | Activity State | -| ------------------ | --------------------- | ------------------ | -| `SessionStart` | Session initialized | - | -| `UserPromptSubmit` | User submitted prompt | `active` | -| `AssistantMessage` | Assistant response | - | -| `ToolUse` | Agent used a tool | `active` | -| `Stop` | Session stopped | `idle` or `exited` | - -### Hook Contract - -All hook callbacks follow this contract: - -```bash -# Hook command format -ao hooks - -# Input (stdin) -# JSON payload from agent (agent-specific format) - -# Output (stdout) -# None (discarded) - -# Exit code -# 0 = success (always, even on delivery failure) -``` - -### Hook Behavior - -```mermaid -flowchart TD - Trigger[Agent Event Triggered] --> Execute[Execute Hook Command] - Execute --> ReadEnv[Read AO_SESSION_ID
from environment] - ReadEnv --> CheckSession{Is AO Session?} - CheckSession -->|No| Exit[Exit 0 - ignore] - CheckSession -->|Yes| ReadStdin[Read JSON Payload
from stdin] - ReadStdin --> Derive[Derive Activity State
from event] - Derive --> Post[POST to Daemon
/api/v1/sessions/{id}/activity] - Post --> Success{Success?} - Success -->|Yes| Update[Daemon updates
activity_state] - Success -->|No| Log[Log to hooks.log
under AO_DATA_DIR] - Update --> Exit - Log --> Exit - -``` - -**Critical rules:** - -1. **Always exit 0** — Hook failure must never break the user's agent -2. **Log failures** — Append to `hooks.log` under `AO_DATA_DIR` -3. **Best-effort delivery** — The daemon may be temporarily unavailable -4. **Idempotent** — Duplicate hook deliveries are safe - -### Hook Installation Patterns - -Different agents use different hook mechanisms: - -#### Claude Code / Compatible Agents - -```json -// .claude/hooks.json -{ - "SessionStart": ["ao hooks claude-code SessionStart"], - "UserPromptSubmit": ["ao hooks claude-code UserPromptSubmit"], - "Stop": ["ao hooks claude-code Stop"] -} -``` - -#### Factory Droid - -```json -// .factory/hooks.json -{ - "hooks": [ - { - "event": "agent:beforeThinking", - "command": "ao hooks droid beforeThinking" - }, - { - "event": "agent:afterThinking", - "command": "ao hooks droid afterThinking" - } - ] -} -``` - -#### Codex (Session Flags) - -```bash -# Codex passes hooks as session flags -codex -c 'hooks.SessionStart=["ao hooks codex SessionStart"]' \ - -c 'hooks.Stop=["ao hooks codex Stop"]' \ - --dangerously-bypass-hook-trust -``` - ---- - -## Session Metadata - -### Metadata Keys - -Hook callbacks persist normalized keys in the session metadata JSON blob: - -```go -const ( - // MetadataKeyAgentSessionID is the native agent's session identifier. - MetadataKeyAgentSessionID = "agentSessionId" - - // MetadataKeyTitle is the display title from the first user prompt. - MetadataKeyTitle = "title" - - // MetadataKeySummary is the display summary from the final assistant message. - MetadataKeySummary = "summary" -) -``` - -### Metadata Flow - -```mermaid -sequenceDiagram - participant Hook as Hook Callback - participant Daemon as AO Daemon - participant Store as SQLite Store - participant UI as Dashboard - - Note over Hook: User submits first prompt - Hook->>Daemon: POST /activity
{state: "active", title: "..."} - Daemon->>Store: Update session metadata
metadata.title = "..." - Store->>Store: CDC: session.updated - Store->>UI: SSE: session.updated - UI->>UI: Update session title - - Note over Hook: Agent finishes work - Hook->>Daemon: POST /activity
{state: "idle", summary: "..."} - Daemon->>Store: Update session metadata
metadata.summary = "..." - Store->>Store: CDC: session.updated - Store->>UI: SSE: session.updated - UI->>UI: Update session summary -``` - -### Metadata Persistence - -```mermaid -flowchart LR - Hook[Hook Callback] --> POST[POST Activity] - POST --> LCM[Lifecycle Manager] - LCM --> Update[Update Session Record] - Update --> Metadata[Set Metadata Fields] - Metadata --> CDC[CDC Broadcast] - CDC --> UI[Dashboard Updates] - -``` - ---- - -## Agent Lifecycle - -### Spawn Flow - -```mermaid -sequenceDiagram - participant User as User - participant UI as Dashboard - participant API as HTTP API - participant Mgr as Session Manager - participant Adapter as Agent Adapter - participant Runtime as Runtime - participant Agent as Agent Process - - User->>UI: Click "Spawn Session" - UI->>API: POST /sessions - API->>Mgr: Spawn(config) - - Note over Mgr: 1. Create session row - Mgr->>Mgr: Insert session with empty metadata - - Note over Mgr: 2. Prepare workspace - Mgr->>Adapter: GetAgentHooks(config) - Adapter->>Adapter: Write hook config - Adapter-->>Mgr: Hooks installed - - Note over Mgr: 3. Build launch command - Mgr->>Adapter: GetLaunchCommand(config) - Adapter-->>Mgr: Launch command argv - - Note over Mgr: 4. Execute in runtime - Mgr->>Runtime: Execute(agent command) - Runtime->>Agent: Spawn process - - Note over Agent: Agent starts - Agent->>Agent: Execute SessionStart hook - Agent->>API: POST /activity (agentSessionId) - API->>API: Update metadata - - Agent-->>Runtime: Running - Runtime-->>Mgr: Session active - Mgr-->>API: Session response - API-->>UI: Session created -``` - -### Restore Flow - -```mermaid -sequenceDiagram - participant User as User - participant UI as Dashboard - participant API as HTTP API - participant Mgr as Session Manager - participant Adapter as Agent Adapter - participant Runtime as Runtime - participant Agent as Agent Process - - User->>UI: Click "Restore Session" - UI->>API: POST /sessions/{id}/restore - API->>Mgr: Restore(session) - - Note over Mgr: Check adapter supports restore - Mgr->>Adapter: GetRestoreCommand(config) - - alt Restore supported - Adapter-->>Mgr: Restore command - Mgr->>Runtime: Execute(restore command) - Runtime->>Agent: Spawn process - Agent-->>Mgr: Running - Mgr-->>API: Session restored - else Restore not supported - Adapter-->>Mgr: (cmd, false, nil) - Mgr->>Mgr: Fresh spawn instead - Mgr-->>API: Session created (new) - end - - API-->>UI: Session response -``` - -### Activity Flow - -```mermaid -stateDiagram-v2 - [*] --> Spawning: Spawn() - Spawning --> Idle: Agent starts - Idle --> Active: User prompt - Active --> Active: Agent working - Active --> Waiting: Needs input - Waiting --> Active: User responds - Active --> Idle: Agent completes - Idle --> Exited: Agent exits - Active --> Exited: Agent crashes - Exited --> [*] - - note right of Active - Hook: UserPromptSubmit - Hook: ToolUse - activity_state = "active" - end note - - note right of Waiting - Hook: WaitingForInput - activity_state = "waiting_input" - end note - - note right of Idle - Hook: Stop - activity_state = "idle" - end note - - note right of Exited - Hook: Stop - activity_state = "exited" - is_terminated = true - end note -``` - ---- - -## Implementation Guide - -### Adapter Template - -```go -package myagent - -import ( - "context" - "fmt" -) - -// Plugin is the MyAgent adapter. -type Plugin struct { - // Adapter state (e.g., resolved binary path) -} - -// New returns a ready-to-register MyAgent adapter. -func New() *Plugin { - return &Plugin{} -} - -// GetConfigSpec describes the agent configuration. -func (p *Plugin) GetConfigSpec() ports.ConfigSpec { - return ports.ConfigSpec{ - Permissions: ports.PermissionReadWrite, // or ReadOnly, WriteOnly - SupportsSystemPrompt: true, - SupportsRestore: true, - } -} - -// GetLaunchCommand builds the native agent command. -func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ([]string, error) { - // 1. Validate config - if err := cfg.Config.Validate(); err != nil { - return nil, fmt.Errorf("myagent: %w", err) - } - - // 2. Resolve binary - binary, err := p.resolveBinary(ctx) - if err != nil { - return nil, err - } - - // 3. Build command - cmd := []string{binary} - - // Add flags (model, permissions, etc.) - if cfg.Config.Model != "" { - cmd = append(cmd, "--model", cfg.Config.Model) - } - - // Add system prompt if supported - if cfg.SystemPrompt != "" { - cmd = append(cmd, "--system-prompt", cfg.SystemPrompt) - } - - // Add prompt - if cfg.Prompt != "" { - cmd = append(cmd, "--prompt", cfg.Prompt) - } - - return cmd, nil -} - -// GetPromptDeliveryStrategy reports how the prompt is delivered. -func (p *Plugin) GetPromptDeliveryStrategy() ports.PromptDeliveryStrategy { - return ports.StrategyArgv // or StrategyStdin -} - -// GetAgentHooks installs AO hooks. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - // 1. Read existing config - config, err := p.readConfig(cfg.WorkspacePath) - if err != nil { - return err - } - - // 2. Add AO hooks (preserving user hooks) - config = p.addHooks(config, cfg.SessionID) - - // 3. Write updated config - if err := p.writeConfig(cfg.WorkspacePath, config); err != nil { - return err - } - - // 4. Ensure .gitignore coverage - if err := p.ensureGitignore(cfg.WorkspacePath); err != nil { - return err - } - - return nil -} - -// GetRestoreCommand builds a restore command. -func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) ([]string, bool, error) { - // 1. Extract native session ID from metadata - sessionID := cfg.Session.Metadata[ports.MetadataKeyAgentSessionID] - if sessionID == "" { - return nil, false, nil // Restore not supported - } - - // 2. Build restore command - binary, err := p.resolveBinary(ctx) - if err != nil { - return nil, false, err - } - - cmd := []string{ - binary, - "--resume", sessionID, - // Re-apply permissions and system prompt - } - - return cmd, true, nil -} - -// SessionInfo returns normalized metadata. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], - } - - // Return hasData=true if any field is populated - hasData := info.AgentSessionID != "" || info.Title != "" || info.Summary != "" - - return info, hasData, nil -} - -// UninstallHooks removes AO hooks. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - // Remove AO hook entries while preserving user hooks - config, err := p.readConfig(workspacePath) - if err != nil { - return err - } - - config = p.removeHooks(config) - - return p.writeConfig(workspacePath, config) -} -``` - -### Hook Implementation - -```go -// Add hooks to the agent's config -func (p *Plugin) addHooks(config map[string]any, sessionID string) map[string]any { - // Define AO hook commands - aoHooks := map[string]string{ - "SessionStart": "ao hooks myagent SessionStart", - "UserPromptSubmit": "ao hooks myagent UserPromptSubmit", - "Stop": "ao hooks myagent Stop", - } - - // Merge with existing config, preserving user hooks - for event, command := range aoHooks { - if config[event] == nil { - config[event] = []string{command} - } else { - // Deduplicate: check if AO hook already exists - hooks := config[event].([]string) - found := false - for _, h := range hooks { - if strings.HasPrefix(h, "ao hooks myagent") { - found = true - break - } - } - if !found { - config[event] = append(hooks, command) - } - } - } - - return config -} -``` - -### Gitignore Enforcement - -```go -import "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" - -func (p *Plugin) ensureGitignore(workspacePath string) error { - // Every file the adapter writes must be gitignored - filesToIgnore := []string{ - ".myagent/config.json", - ".myagent/hooks.json", - } - - return hookutil.EnsureWorkspaceGitignore(workspacePath, ".myagent/") -} -``` - ---- - -## Supported Agents - -### Current Adapters - -AO currently supports 23+ agent adapters: - -```mermaid -mindmap - root((Agent Adapters)) - Anthropic - Claude Code - OpenAI - Codex - Cursor - Cursor - OpenCode - OpenCode - Aider - Aider - Amp - Amp Code - Goose - Goose - GitHub - Copilot - xAI - Grok - Alibaba - Qwen Code - Moonshot - Kimi Code - Reverb - Crush - Cline - Cline - Factory - Droid - Devin - Augment - Auggie - Continue - Continue - Kiro - Kiro - Kilo - Kilo Code - Agy - Agy - Roo - Roo Code - Windsurf - Windsurf -``` - -### Adapter Locations - -``` -backend/internal/adapters/agent/ -├── agy/ -├── aider/ -├── amp/ -├── augie/ -├── claudecode/ -├── clone/ -├── codex/ -├── continue/ -├── cursor/ -├── devin/ -├── droid/ -├── grok/ -├── goose/ -├── kilo/ -├── kimi/ -├── kiro/ -├── opencode/ -├── qwen/ -├── roo/ -├── crush/ -├── windsurf/ -└── ... -``` - -### Adapter Capabilities - -| Agent | Permissions | System Prompt | Restore | Hooks | -| ----------- | ----------- | ------------- | ------- | ----------------- | -| Claude Code | ✓ | ✓ | ✓ | ✓ | -| Codex | ✓ | ✓ | ✓ | ✓ (session flags) | -| Cursor | ✓ | ✓ | ✗ | ✗ | -| OpenCode | ✓ | ✓ | ✗ | ✗ | -| Aider | ✓ | ✓ | ✓ | ✓ | -| Amp | ✓ | ✓ | ✓ | ✓ | -| Grok | ✓ | ✓ | ✓ | ✓ (Claude compat) | -| ... | ... | ... | ... | ... | - ---- - -## Testing - -### Unit Tests - -```go -func TestGetLaunchCommand(t *testing.T) { - p := New() - cfg := ports.LaunchConfig{ - SessionID: "test-session", - Prompt: "Write a function", - Config: domain.AgentConfig{ - Model: "gpt-4", - }, - } - - cmd, err := p.GetLaunchCommand(context.Background(), cfg) - assert.NoError(t, err) - assert.Contains(t, cmd, "myagent") - assert.Contains(t, cmd, "--model", "gpt-4") - assert.Contains(t, cmd, "Write a function") -} -``` - -### Integration Tests - -```go -func TestAgentE2E(t *testing.T) { - // Skip if agent not installed - if !agentInstalled(t) { - t.Skip("myagent not installed") - } - - // Create test project - ctx := context.Background() - project := createTestProject(t) - - // Spawn session - session := spawnSession(t, ctx, project.ID, "myagent", "Hello") - - // Wait for activity - waitForActivity(t, ctx, session.ID, "active") - - // Verify metadata - info := getSessionInfo(t, ctx, session.ID) - assert.NotEmpty(t, info.AgentSessionID) - assert.NotEmpty(t, info.Title) - - // Kill session - killSession(t, ctx, session.ID) -} -``` - -### Conformance Tests - -```go -// TestGetAgentHooksFootprintIsGitignored verifies all adapter-written -// files are covered by .gitignore. -func TestGetAgentHooksFootprintIsGitignored(t *testing.T) { - registry := agentRegistry.New() - for _, adapter := range registry.List() { - t.Run(adapter, func(t *testing.T) { - tmpDir := t.TempDir() - cfg := ports.WorkspaceHookConfig{ - WorkspacePath: tmpDir, - SessionID: "test-session", - } - - agent := registry.Get(adapter) - err := agent.GetAgentHooks(context.Background(), cfg) - assert.NoError(t, err) - - // Verify all written files are gitignored - verifyGitignore(t, tmpDir) - }) - } -} -``` - -### Hook Tests - -```go -func TestHookDelivery(t *testing.T) { - // Mock daemon endpoint - server := mockActivityServer(t) - defer server.Close() - - // Simulate hook callback - payload := map[string]any{ - "state": "active", - "title": "Test session", - } - - cmd := exec.Command("ao", "hooks", "myagent", "UserPromptSubmit", "test-session") - cmd.Env = append(cmd.Env, - fmt.Sprintf("AO_SESSION_ID=%s", "test-session"), - fmt.Sprintf("AO_DAEMON_URL=%s", server.URL), - ) - - stdin, _ := cmd.StdinPipe() - json.NewEncoder(stdin).Encode(payload) - stdin.Close() - - err := cmd.Run() - assert.NoError(t, err) - - // Verify daemon received the activity - assert.ActivityReceived(t, server, "active") -} -``` - ---- - -## Summary - -**Key points:** - -1. **Port contract** — All agents implement `ports.Agent` -2. **Hooks are critical** — Activity signals and metadata flow through hooks -3. **Always gitignore** — Every adapter-written file must be covered -4. **Metadata from hooks** — `SessionInfo` reads from metadata, not files -5. **Preserve user hooks** — Never delete user configuration -6. **Exit 0 always** — Hook failure must never break the agent - -**Implementation checklist:** - -- [ ] Implement `ports.Agent` interface -- [ ] Write hook installation/removal -- [ ] Ensure all files are gitignored -- [ ] Implement `SessionInfo` from metadata -- [ ] Add unit tests -- [ ] Add integration tests -- [ ] Add conformance tests -- [ ] Register in daemon wiring diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 8bef18801..fe960f8e0 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -4,6 +4,40 @@ */ export interface paths { + "/api/v1/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Return cached supported and locally installed agent adapters */ + get: operations["listAgents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/agents/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Refresh the cached local agent adapter catalog */ + post: operations["refreshAgents"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/events": { parameters: { query?: never; @@ -512,6 +546,15 @@ export interface components { model?: string; permissions?: string; }; + AgentInfo: { + /** + * @description Advisory local auth probe result. authorized means a recent local probe passed; spawn remains the authoritative validation point. + * @enum {string} + */ + authStatus?: "authorized" | "unauthorized" | "unknown"; + id: string; + label: string; + }; ClaimPRRequest: { allowTakeover?: null | boolean; pr: string; @@ -587,6 +630,14 @@ export interface components { ok: boolean; sessionId: string; }; + ListAgentsResponse: { + /** @description Compatibility list of installed agents whose local auth probe recently returned authorized. Advisory and stale-prone; spawn may still fail. */ + authorized: components["schemas"]["AgentInfo"][]; + /** @description Agents whose binary resolved during the latest best-effort local catalog probe. */ + installed: components["schemas"]["AgentInfo"][]; + /** @description Agents supported by this daemon build. */ + supported: components["schemas"]["AgentInfo"][]; + }; ListNotificationsResponse: { notifications: components["schemas"]["NotificationResponse"][]; }; @@ -937,6 +988,82 @@ export interface components { } export type $defs = Record; export interface operations { + listAgents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListAgentsResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + refreshAgents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListAgentsResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; streamEvents: { parameters: { query?: { diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx index 6101d11e0..4c0f08697 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx @@ -1,17 +1,36 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { agentsQueryKey } from "../hooks/useAgentsQuery"; import { CreateProjectAgentSheet } from "./CreateProjectAgentSheet"; function renderSheet(onSubmit = vi.fn().mockResolvedValue(undefined)) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(agentsQueryKey, { + supported: [ + { id: "claude-code", label: "claude-code" }, + { id: "codex", label: "codex" }, + ], + installed: [ + { id: "claude-code", label: "claude-code", authStatus: "authorized" }, + { id: "codex", label: "codex", authStatus: "authorized" }, + ], + authorized: [ + { id: "claude-code", label: "claude-code", authStatus: "authorized" }, + { id: "codex", label: "codex", authStatus: "authorized" }, + ], + }); render( - undefined} - onSubmit={onSubmit} - open={true} - path="/repo/new-project" - />, + + undefined} + onSubmit={onSubmit} + open={true} + path="/repo/new-project" + /> + , ); return onSubmit; } diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index 5f9e1a67a..745ac9a72 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -1,15 +1,19 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as Dialog from "@radix-ui/react-dialog"; -import { X } from "lucide-react"; +import { TriangleAlert, X } from "lucide-react"; import { memo, useEffect, useState } from "react"; -import { AGENT_OPTIONS, DEFAULT_PROJECT_AGENT } from "../lib/agent-options"; +import type { components } from "../../api/schema"; +import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; +import { AGENT_OPTIONS } from "../lib/agent-options"; import { buildIntake, type IntakeForm, IntakeFields, intakeNeedsRule } from "./IntakeFields"; import { Button } from "./ui/button"; import { Label } from "./ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; -import type { components } from "../../api/schema"; type TrackerIntakeConfig = components["schemas"]["TrackerIntakeConfig"]; +type AgentInfo = components["schemas"]["AgentInfo"]; + export type CreateProjectAgentSelection = { workerAgent: string; orchestratorAgent: string; @@ -35,16 +39,36 @@ export function CreateProjectAgentSheet({ open, path, }: CreateProjectAgentSheetProps) { - const [workerAgent, setWorkerAgent] = useState(DEFAULT_PROJECT_AGENT); - const [orchestratorAgent, setOrchestratorAgent] = useState(DEFAULT_PROJECT_AGENT); + const queryClient = useQueryClient(); + const agentsQuery = useQuery({ + ...agentsQueryOptions, + enabled: open, + }); + const refreshAgentsMutation = useMutation({ + mutationFn: refreshAgents, + onSuccess: (next) => queryClient.setQueryData(agentsQueryKey, next), + }); + const agents = agentsQuery.data; + const installedAgents = agents?.installed ?? []; + const agentOptions = agents?.authorized ?? []; + const supportedAgents = agents?.supported ?? []; + const isLoadingAgents = agents === undefined && agentsQuery.isFetching; + const agentsError = agentsQuery.isError + ? agentsQuery.error instanceof Error + ? agentsQuery.error.message + : "Could not load agent catalog." + : null; + const [workerAgent, setWorkerAgent] = useState(""); + const [orchestratorAgent, setOrchestratorAgent] = useState(""); const [intake, setIntake] = useState(EMPTY_INTAKE); const intakeIncomplete = intakeNeedsRule(intake); - const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isCreating; + const canSubmit = + workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isCreating && !isLoadingAgents; useEffect(() => { if (!open) { - setWorkerAgent(DEFAULT_PROJECT_AGENT); - setOrchestratorAgent(DEFAULT_PROJECT_AGENT); + setWorkerAgent(""); + setOrchestratorAgent(""); setIntake(EMPTY_INTAKE); } }, [open, path]); @@ -86,6 +110,10 @@ export function CreateProjectAgentSheet({ label="Worker agent" placeholder="Select worker agent" value={workerAgent} + authorized={agentOptions} + installed={installedAgents} + supported={supportedAgents} + disabled={isLoadingAgents} onChange={setWorkerAgent} /> + {isLoadingAgents &&

Loading agents...

} + +
+ Agent availability is cached. + +
+ + {agentsError && ( +
+ {agentsError} + +
+ )} + + {refreshAgentsMutation.isError && ( +
+ {refreshAgentsMutation.error instanceof Error + ? refreshAgentsMutation.error.message + : "Could not refresh agent catalog."} +
+ )} +
setIntake((f) => ({ ...f, ...patch }))} compact />
@@ -123,33 +190,78 @@ export function CreateProjectAgentSheet({ } export const RequiredAgentField = memo(function RequiredAgentField({ + authorized, + disabled = false, id, invalid = false, + installed, label, onChange, placeholder, + supported, value, }: { + authorized?: AgentInfo[]; + disabled?: boolean; id: string; invalid?: boolean; + installed?: AgentInfo[]; label: string; onChange: (value: string) => void; placeholder: string; + supported?: AgentInfo[]; value: string; }) { + const fallbackAgents: AgentInfo[] = AGENT_OPTIONS.map((agent) => ({ id: agent, label: agent })); + const supportedAgents = supported ?? fallbackAgents; + const installedAgents = installed ?? supportedAgents; + const authorizedAgents = authorized ?? supportedAgents; + const authorizedIds = new Set(authorizedAgents.map((agent) => agent.id)); + const installedById = new Map(installedAgents.map((agent) => [agent.id, agent])); + const options = supportedAgents + .map((agent) => { + const installedAgent = installedById.get(agent.id); + const authStatus = installedAgent?.authStatus; + const isAuthorized = authorizedIds.has(agent.id) || authStatus === "authorized"; + const isAuthUnknown = Boolean(installedAgent) && !isAuthorized && authStatus !== "unauthorized"; + const isSelectable = isAuthorized || isAuthUnknown; + const rank = isAuthorized ? 0 : isAuthUnknown ? 1 : installedAgent ? 2 : 3; + return { + ...agent, + disabled: !isSelectable, + rank, + reason: !installedAgent ? "Needs install" : isAuthUnknown ? "Auth unknown" : !isAuthorized ? "Needs auth" : "", + warning: isAuthUnknown, + }; + }) + .sort((a, b) => a.rank - b.rank || a.label.localeCompare(b.label) || a.id.localeCompare(b.id)); + return (
- - - {AGENT_OPTIONS.map((agent) => ( - - {agent} + + {options.map((agent) => ( + + + {agent.label} + {agent.reason && ( + + {agent.warning && + )} + ))} diff --git a/frontend/src/renderer/components/NewTaskDialog.test.tsx b/frontend/src/renderer/components/NewTaskDialog.test.tsx index d7e480f5a..9ec8474bc 100644 --- a/frontend/src/renderer/components/NewTaskDialog.test.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.test.tsx @@ -16,7 +16,9 @@ vi.mock("../lib/api-client", () => ({ }, apiErrorMessage: (error: unknown, fallback = "Request failed") => { if (typeof error === "object" && error !== null && "message" in error) { - return String((error as { message: unknown }).message); + const body = error as { code?: unknown; message: unknown }; + const message = String(body.message); + return typeof body.code === "string" && body.code !== "" ? `${message} (${body.code})` : message; } return fallback; }, @@ -37,10 +39,37 @@ function spawnBody() { return (postMock.mock.calls[0][1] as { body: Record }).body; } +async function waitForAgentCatalog() { + await waitFor(() => expect(screen.getAllByText("Claude Code").length).toBeGreaterThan(0)); +} + beforeEach(() => { - getMock.mockReset().mockResolvedValue({ - data: { status: "ok", project: { id: "proj-1", config: { worker: { agent: "claude-code" } } } }, - error: undefined, + getMock.mockReset().mockImplementation(async (path: string) => { + if (path === "/api/v1/agents") { + return { + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "cursor", label: "Cursor" }, + { id: "kiro", label: "Kiro" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "cursor", label: "Cursor", authStatus: "authorized" }, + { id: "kiro", label: "Kiro", authStatus: "unknown" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "cursor", label: "Cursor", authStatus: "authorized" }, + ], + }, + error: undefined, + }; + } + return { + data: { status: "ok", project: { id: "proj-1", config: { worker: { agent: "claude-code" } } } }, + error: undefined, + }; }); postMock.mockReset().mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined }); }); @@ -52,7 +81,7 @@ describe("NewTaskDialog", () => { const { onCreated, onOpenChange } = renderDialog(); const user = userEvent.setup(); - await screen.findByText("claude-code"); + await waitForAgentCatalog(); await user.type(screen.getByLabelText("Title"), "Fix fallback renderer"); await user.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails."); @@ -71,18 +100,18 @@ describe("NewTaskDialog", () => { }); expect(onCreated).toHaveBeenCalledWith("task-1"); expect(onOpenChange).toHaveBeenCalledWith(false); - }); + }, 10_000); - it("sends the chosen harness when the user overrides the default, including agents beyond the legacy four", async () => { + it("sends the chosen harness when the user overrides the default", async () => { renderDialog(); const user = userEvent.setup(); - await screen.findByText("claude-code"); + await waitForAgentCatalog(); await user.type(screen.getByLabelText("Title"), "T"); await user.type(screen.getByLabelText("Brief"), "B"); await user.click(screen.getByRole("combobox", { name: "Agent" })); - await user.click(await screen.findByRole("option", { name: "cursor" })); + await user.click(await screen.findByRole("option", { name: "Cursor" })); await user.click(screen.getByRole("button", { name: "Start task" })); @@ -90,6 +119,25 @@ describe("NewTaskDialog", () => { expect(spawnBody().harness).toBe("cursor"); }); + it("allows selecting an installed agent with unknown auth", async () => { + renderDialog(); + const user = userEvent.setup(); + await waitForAgentCatalog(); + + await user.click(screen.getByRole("combobox", { name: "Agent" })); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual(["Claude Code", "Cursor", "KiroAuth unknown"]); + expect(options[2]).not.toHaveAttribute("aria-disabled", "true"); + await user.click(options[2]); + + await user.type(screen.getByLabelText("Title"), "T"); + await user.type(screen.getByLabelText("Brief"), "B"); + await user.click(screen.getByRole("button", { name: "Start task" })); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(spawnBody().harness).toBe("kiro"); + }); + it("requires both title and brief", async () => { renderDialog(); const user = userEvent.setup(); @@ -99,4 +147,33 @@ describe("NewTaskDialog", () => { expect(await screen.findByText("Title and brief are required.")).toBeInTheDocument(); expect(postMock).not.toHaveBeenCalled(); }); + + it.each([ + { + code: "AGENT_BINARY_NOT_FOUND", + message: "agent binary not found on PATH", + }, + { + code: "RUNTIME_PREREQUISITE_MISSING", + message: "tmux required on macOS/Linux but not in PATH", + }, + { + code: "INTERNAL", + message: "runtime launch failed", + }, + ])("displays daemon spawn errors for $code", async ({ code, message }) => { + postMock.mockResolvedValueOnce({ + data: undefined, + error: { code, message }, + }); + renderDialog(); + const user = userEvent.setup(); + await waitForAgentCatalog(); + + await user.type(screen.getByLabelText("Title"), "Fix fallback renderer"); + await user.type(screen.getByLabelText("Brief"), "Restore fallback renderer."); + await user.click(screen.getByRole("button", { name: "Start task" })); + + expect(await screen.findByText(`${message} (${code})`)).toBeInTheDocument(); + }); }); diff --git a/frontend/src/renderer/components/NewTaskDialog.tsx b/frontend/src/renderer/components/NewTaskDialog.tsx index cef37d5b8..80a9bcddc 100644 --- a/frontend/src/renderer/components/NewTaskDialog.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.tsx @@ -1,5 +1,5 @@ import * as Dialog from "@radix-ui/react-dialog"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, X } from "lucide-react"; import { type FormEvent, useEffect, useId, useState } from "react"; import { Button } from "./ui/button"; @@ -8,6 +8,7 @@ import { RequiredAgentField } from "./CreateProjectAgentSheet"; import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import type { AgentProvider } from "../types/workspace"; +import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; type Project = components["schemas"]["Project"]; @@ -19,6 +20,7 @@ type NewTaskDialogProps = { }; export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewTaskDialogProps) { + const queryClient = useQueryClient(); const titleId = useId(); const promptId = useId(); const branchId = useId(); @@ -43,7 +45,16 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT return data.project as Project; }, }); + const agentsQuery = useQuery({ + ...agentsQueryOptions, + enabled: open, + }); + const refreshAgentsMutation = useMutation({ + mutationFn: refreshAgents, + onSuccess: (next) => queryClient.setQueryData(agentsQueryKey, next), + }); const defaultWorkerAgent = projectQuery.data?.config?.worker?.agent ?? ""; + const agentCatalog = agentsQuery.data; useEffect(() => { if (!open) { @@ -93,6 +104,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT onCreated(data.session.id); onOpenChange(false); } catch (err) { + void queryClient.invalidateQueries({ queryKey: agentsQueryKey }); setError(err instanceof Error ? err.message : "Unable to start task"); } finally { setIsSubmitting(false); @@ -150,16 +162,30 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
- { - setAgent(value); - setAgentTouched(true); - }} - /> +
+ { + setAgent(value); + setAgentTouched(true); + }} + /> + +
)} + {refreshAgentsMutation.isError && ( +
+ {refreshAgentsMutation.error instanceof Error + ? refreshAgentsMutation.error.message + : "Could not refresh agent catalog."} +
+ )} +
+
+ {refreshAgentsMutation.isError && ( +

+ {refreshAgentsMutation.error instanceof Error + ? refreshAgentsMutation.error.message + : "Could not refresh agent catalog."} +

+ )} + {missingRequiredAgent && ( +

Worker and orchestrator agents are required.

+ )} - claude-code (default) + Project default {REVIEWER_OPTIONS.map((reviewer) => ( {reviewer} diff --git a/frontend/src/renderer/components/SessionsBoard.test.tsx b/frontend/src/renderer/components/SessionsBoard.test.tsx new file mode 100644 index 000000000..2b55fcb47 --- /dev/null +++ b/frontend/src/renderer/components/SessionsBoard.test.tsx @@ -0,0 +1,40 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { navigateMock, workspaceQueryMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), + workspaceQueryMock: vi.fn(), +})); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock("../hooks/useWorkspaceQuery", () => ({ + useWorkspaceQuery: workspaceQueryMock, +})); + +import { SessionsBoard } from "./SessionsBoard"; + +function renderBoard() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +beforeEach(() => { + navigateMock.mockReset(); + workspaceQueryMock.mockReset().mockReturnValue({ data: [], isError: false }); +}); + +describe("SessionsBoard", () => { + it("does not show an agent setup warning on the board", () => { + renderBoard(); + + expect(screen.queryByText(/reload agents/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 261ca406c..6e85b89fa 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -1,6 +1,8 @@ -import { useState, type KeyboardEvent } from "react"; +import { type KeyboardEvent, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; + +import { DashboardSubhead } from "./DashboardSubhead"; import { Plus } from "lucide-react"; import { type AttentionZone, @@ -11,7 +13,6 @@ import { } from "../types/workspace"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; -import { DashboardSubhead } from "./DashboardSubhead"; import { OrchestratorIcon } from "./icons"; import { NewTaskDialog } from "./NewTaskDialog"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; @@ -271,13 +272,10 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: ( const showBranch = branch !== "" && !sameLabel(branch, session.title) && !sameLabel(branch, session.id); const prSummaries = sessionPRDisplaySummaries(session, useSessionScmSummary(session.id).data); const handleKeyDown = (event: KeyboardEvent) => { - if (event.target !== event.currentTarget) { - return; - } - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onOpen(); - } + if (event.currentTarget !== event.target) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onOpen(); }; return (
({ +const { getMock, navigateMock, mockParams, renameSessionMock } = vi.hoisted(() => ({ + getMock: vi.fn(), navigateMock: vi.fn(), mockParams: { projectId: undefined as string | undefined }, renameSessionMock: vi.fn().mockResolvedValue(undefined), @@ -19,12 +21,23 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { return { ...actual, useNavigate: () => navigateMock, - useParams: () => mockParams, + useParams: () => ({}), useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => unknown }) => select({ location: { pathname: "/" } }), }; }); +vi.mock("../lib/api-client", () => ({ + apiClient: { GET: getMock }, + apiErrorMessage: (error: unknown) => { + if (error instanceof Error) return error.message; + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { + return error.message; + } + return "Request failed"; + }, +})); + const workspace: WorkspaceSummary = { id: "proj-1", name: "Project One", @@ -51,15 +64,33 @@ type RemoveProjectHandler = (projectId: string) => Promise; function renderSidebar({ onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler, onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler, + seedAgents = true, workspaces = [workspace], }: { onCreateProject?: CreateProjectHandler; onRemoveProject?: RemoveProjectHandler; + seedAgents?: boolean; workspaces?: WorkspaceSummary[]; } = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); + if (seedAgents) { + queryClient.setQueryData(agentsQueryKey, { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }); + } render( @@ -81,6 +112,24 @@ async function chooseOption(trigger: HTMLElement, optionName: string) { } beforeEach(() => { + getMock.mockReset(); + getMock.mockResolvedValue({ + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }, + error: undefined, + }); navigateMock.mockReset(); renameSessionMock.mockReset().mockResolvedValue(undefined); mockParams.projectId = undefined; @@ -145,8 +194,8 @@ describe("Sidebar", () => { expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); const dialog = screen.getByRole("dialog", { name: "Project agents" }); expect(dialog).toHaveClass("left-1/2", "top-1/2", "-translate-x-1/2", "-translate-y-1/2"); - await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "codex"); - await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "claude-code"); + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Codex"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); await user.click(screen.getByRole("button", { name: "Create and start" })); await waitFor(() => @@ -158,26 +207,102 @@ describe("Sidebar", () => { ); }); - it("opens global settings from the footer menu when no project is selected", async () => { + it("shows needs-auth agents as unavailable while keeping authorized agents selectable", async () => { const user = userEvent.setup(); - renderSidebar(); + const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler; + window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project"); + getMock.mockResolvedValueOnce({ + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "cursor", label: "Cursor" }, + { id: "aider", label: "Aider" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "cursor", label: "Cursor", authStatus: "unauthorized" }, + ], + authorized: [{ id: "claude-code", label: "Claude Code", authStatus: "authorized" }], + }, + error: undefined, + }); + renderSidebar({ onCreateProject, seedAgents: false }); - await user.click(screen.getAllByLabelText("Settings")[0]); - await user.click(await screen.findByRole("menuitem", { name: "Global settings" })); + await user.click(screen.getByLabelText("New project")); + expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); - expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" }); + await user.click(screen.getByRole("combobox", { name: "Worker agent" })); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "Claude Code", + "CursorNeeds auth", + "AiderNeeds install", + ]); + expect(options[1]).toHaveAttribute("aria-disabled", "true"); + expect(options[2]).toHaveAttribute("aria-disabled", "true"); + await user.keyboard("{Escape}"); + + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Claude Code"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); + await user.click(screen.getByRole("button", { name: "Create and start" })); + + await waitFor(() => + expect(onCreateProject).toHaveBeenCalledWith(expect.objectContaining({ workerAgent: "claude-code" })), + ); }); - it("shows both project and global settings in the footer menu when a project is selected", async () => { - mockParams.projectId = "proj-1"; + it("updates project agent options when the catalog loads after the dialog opens", async () => { const user = userEvent.setup(); - renderSidebar(); + const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler; + window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project"); + let resolveAgents!: (value: { + data: { + supported: { id: string; label: string }[]; + installed: { id: string; label: string }[]; + authorized: { id: string; label: string; authStatus: "authorized" }[]; + }; + error: undefined; + }) => void; + getMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveAgents = resolve; + }), + ); + renderSidebar({ onCreateProject, seedAgents: false }); - await user.click(screen.getAllByLabelText("Settings")[0]); - expect(await screen.findByRole("menuitem", { name: "Project settings" })).toBeInTheDocument(); - await user.click(await screen.findByRole("menuitem", { name: "Global settings" })); + await user.click(screen.getByLabelText("New project")); + expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create and start" })).toBeDisabled(); - expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" }); + resolveAgents({ + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }, + error: undefined, + }); + + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Codex"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); + await user.click(screen.getByRole("button", { name: "Create and start" })); + + await waitFor(() => + expect(onCreateProject).toHaveBeenCalledWith({ + path: "/repo/new-project", + workerAgent: "codex", + orchestratorAgent: "claude-code", + }), + ); }); it("renames a session inline and persists via the daemon", async () => { diff --git a/frontend/src/renderer/components/ui/select.tsx b/frontend/src/renderer/components/ui/select.tsx index e8fd1f0be..c274cc642 100644 --- a/frontend/src/renderer/components/ui/select.tsx +++ b/frontend/src/renderer/components/ui/select.tsx @@ -65,11 +65,7 @@ function SelectContent({ > {children} diff --git a/frontend/src/renderer/hooks/useAgentsQuery.ts b/frontend/src/renderer/hooks/useAgentsQuery.ts new file mode 100644 index 000000000..6654e16b6 --- /dev/null +++ b/frontend/src/renderer/hooks/useAgentsQuery.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import type { components } from "../../api/schema"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; + +export type AgentCatalog = components["schemas"]["ListAgentsResponse"]; + +export const agentsQueryKey = ["agents"] as const; + +async function fetchAgents(): Promise { + const { data, error } = await apiClient.GET("/api/v1/agents"); + if (error) throw new Error(apiErrorMessage(error)); + return data as AgentCatalog; +} + +export async function refreshAgents(): Promise { + const { data, error } = await apiClient.POST("/api/v1/agents/refresh"); + if (error) throw new Error(apiErrorMessage(error)); + return data as AgentCatalog; +} + +export const agentsQueryOptions = { + queryKey: agentsQueryKey, + queryFn: fetchAgents, + retry: 1, + staleTime: 5 * 60 * 1000, +}; + +export function useAgentsQuery() { + return useQuery(agentsQueryOptions); +} diff --git a/frontend/src/renderer/lib/agent-options.ts b/frontend/src/renderer/lib/agent-options.ts index 39ade7eae..d26c9a4c0 100644 --- a/frontend/src/renderer/lib/agent-options.ts +++ b/frontend/src/renderer/lib/agent-options.ts @@ -23,7 +23,3 @@ export const AGENT_OPTIONS = [ "pi", "autohand", ] as const; - -// The agent new projects use by default, and the fallback for worker/orchestrator -// role fields that have no explicit configuration. Users can change it per project. -export const DEFAULT_PROJECT_AGENT: (typeof AGENT_OPTIONS)[number] = "claude-code"; diff --git a/frontend/src/renderer/lib/api-client.test.ts b/frontend/src/renderer/lib/api-client.test.ts index f72096dd8..df7898253 100644 --- a/frontend/src/renderer/lib/api-client.test.ts +++ b/frontend/src/renderer/lib/api-client.test.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { apiClient, getApiBaseUrl, hasTrustedApiBaseUrl, setApiBaseUrl, subscribeApiBaseUrl } from "./api-client"; +import { + apiClient, + apiErrorMessage, + getApiBaseUrl, + hasTrustedApiBaseUrl, + setApiBaseUrl, + subscribeApiBaseUrl, +} from "./api-client"; describe("apiClient runtime base URL", () => { afterEach(() => { @@ -154,3 +161,20 @@ describe("subscribeApiBaseUrl", () => { expect(listener).not.toHaveBeenCalled(); }); }); + +describe("apiErrorMessage", () => { + it("preserves daemon error codes next to human messages", () => { + expect(apiErrorMessage({ code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" })).toBe( + "agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)", + ); + }); + + it("does not duplicate a code that is already present in the message", () => { + expect( + apiErrorMessage({ + code: "RUNTIME_PREREQUISITE_MISSING", + message: "tmux required (RUNTIME_PREREQUISITE_MISSING)", + }), + ).toBe("tmux required (RUNTIME_PREREQUISITE_MISSING)"); + }); +}); diff --git a/frontend/src/renderer/lib/api-client.ts b/frontend/src/renderer/lib/api-client.ts index 9afdf193d..8bc2551aa 100644 --- a/frontend/src/renderer/lib/api-client.ts +++ b/frontend/src/renderer/lib/api-client.ts @@ -93,8 +93,11 @@ export function apiErrorMessage(error: unknown, fallback = "Request failed"): st if (error instanceof Error) return error.message; if (typeof error === "string" && error !== "") return error; if (typeof error === "object" && error !== null) { - const body = error as { message?: unknown; error?: unknown }; - if (typeof body.message === "string" && body.message !== "") return body.message; + const body = error as { code?: unknown; message?: unknown; error?: unknown }; + const code = typeof body.code === "string" && body.code !== "" ? body.code : ""; + if (typeof body.message === "string" && body.message !== "") { + return code && !body.message.includes(code) ? `${body.message} (${code})` : body.message; + } if (typeof body.error === "string" && body.error !== "") return body.error; } return fallback; diff --git a/frontend/src/renderer/lib/spawn-orchestrator.test.ts b/frontend/src/renderer/lib/spawn-orchestrator.test.ts index 34475155d..a8748fd20 100644 --- a/frontend/src/renderer/lib/spawn-orchestrator.test.ts +++ b/frontend/src/renderer/lib/spawn-orchestrator.test.ts @@ -4,6 +4,14 @@ import { apiClient } from "./api-client"; vi.mock("./api-client", () => ({ apiClient: { POST: vi.fn() }, + apiErrorMessage: (error: unknown, fallback = "Request failed") => { + if (typeof error === "object" && error !== null && "message" in error) { + const body = error as { code?: unknown; message: unknown }; + const message = String(body.message); + return typeof body.code === "string" && body.code !== "" ? `${message} (${body.code})` : message; + } + return fallback; + }, })); describe("spawnOrchestrator", () => { @@ -33,4 +41,14 @@ describe("spawnOrchestrator", () => { body: { projectId: "proj", clean: false }, }); }); + + it("surfaces daemon spawn error messages and codes", async () => { + (apiClient.POST as ReturnType).mockResolvedValue({ + data: undefined, + error: { code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" }, + response: { status: 400 }, + }); + + await expect(spawnOrchestrator("proj")).rejects.toThrow("agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)"); + }); }); diff --git a/frontend/src/renderer/lib/spawn-orchestrator.ts b/frontend/src/renderer/lib/spawn-orchestrator.ts index 1a0c2c81c..13d2f5533 100644 --- a/frontend/src/renderer/lib/spawn-orchestrator.ts +++ b/frontend/src/renderer/lib/spawn-orchestrator.ts @@ -1,4 +1,4 @@ -import { apiClient } from "./api-client"; +import { apiClient, apiErrorMessage } from "./api-client"; /** Spawn the project's orchestrator session via the daemon API. When clean is * true the daemon first tears down any active orchestrator for the project, then @@ -9,10 +9,9 @@ export async function spawnOrchestrator(projectId: string, clean = false): Promi }); if (error || !data?.orchestrator?.id) { - const message = - error && typeof error === "object" && "message" in error && typeof error.message === "string" - ? error.message - : `Failed to spawn orchestrator (${response.status})`; + const message = error + ? apiErrorMessage(error, `Failed to spawn orchestrator (${response.status})`) + : `Failed to spawn orchestrator (${response.status})`; throw new Error(message); } diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index 6dcbb2c42..a6f300d71 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -1,10 +1,11 @@ import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; -import { type CSSProperties, useCallback, useEffect } from "react"; +import { type CSSProperties, useCallback, useEffect, useRef } from "react"; import { ShellTopbar } from "../components/ShellTopbar"; import { Sidebar } from "../components/Sidebar"; import { SidebarProvider } from "../components/ui/sidebar"; import { TitlebarNav } from "../components/TitlebarNav"; +import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; import { useDaemonStatus } from "../hooks/useDaemonStatus"; import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery"; import { apiClient, apiErrorMessage } from "../lib/api-client"; @@ -45,6 +46,7 @@ function ShellLayout() { const workspaceQuery = useWorkspaceQuery(); const workspaces = workspaceQuery.data ?? []; const daemonStatus = useDaemonStatus(queryClient); + const agentCatalogPortRef = useRef(undefined); const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore(); const updateWorkspaces = useCallback( @@ -67,6 +69,10 @@ function ShellLayout() { surface: "project_board", }); void captureRendererEvent("ao.renderer.project_add_requested"); + const status = await refreshDaemonStatus(); + if (status.state !== "ready" || !status.port) { + throw new Error(status.message || "AO daemon is not ready."); + } const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path, @@ -145,6 +151,16 @@ function ShellLayout() { document.documentElement.style.colorScheme = theme; }, [theme]); + useEffect(() => { + if (daemonStatus.state !== "ready" || !daemonStatus.port) return; + if (agentCatalogPortRef.current === daemonStatus.port) return; + + agentCatalogPortRef.current = daemonStatus.port; + void queryClient.invalidateQueries({ queryKey: agentsQueryKey }); + void queryClient.fetchQuery({ ...agentsQueryOptions, queryFn: refreshAgents }); + void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + }, [daemonStatus.port, daemonStatus.state, queryClient]); + // Follow OS appearance only until the user picks a theme explicitly. useEffect(() => { if (readStoredTheme()) return; diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts index 0125bd75a..992db2685 100644 --- a/frontend/src/renderer/types/workspace.test.ts +++ b/frontend/src/renderer/types/workspace.test.ts @@ -124,6 +124,12 @@ describe("findProjectOrchestrator", () => { expect(findProjectOrchestrator([workspaceWith([dead, live, worker])], "skills")).toBe(live); }); + it("prefers the newest live orchestrator when multiple replacements overlap", () => { + const older = sessionWith({ id: "skills-4", kind: "orchestrator", status: "idle", provider: "claude-code" }); + const newer = sessionWith({ id: "skills-5", kind: "orchestrator", status: "working", provider: "codex" }); + expect(findProjectOrchestrator([workspaceWith([older, newer])], "skills")).toBe(newer); + }); + it("returns undefined when every orchestrator is terminated", () => { const dead = sessionWith({ id: "skills-4", kind: "orchestrator", status: "terminated" }); expect(findProjectOrchestrator([workspaceWith([dead])], "skills")).toBeUndefined(); diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index e8bb86f7e..d3e597fa4 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -241,7 +241,14 @@ export function findProjectOrchestrator( projectId: string, ): WorkspaceSession | undefined { const workspace = workspaces.find((w) => w.id === projectId); - return workspace?.sessions.find((session) => isOrchestratorSession(session) && sessionIsActive(session)); + if (!workspace) return undefined; + for (let i = workspace.sessions.length - 1; i >= 0; i -= 1) { + const session = workspace.sessions[i]; + if (isOrchestratorSession(session) && sessionIsActive(session)) { + return session; + } + } + return undefined; } export function workerSessions(sessions: WorkspaceSession[]): WorkspaceSession[] {