Merge remote-tracking branch 'origin/main' into ao/reverbcode-2/issue-2272-prompts

# Conflicts:
#	backend/internal/adapters/agent/copilot/copilot.go
#	backend/internal/adapters/agent/opencode/opencode.go
#	backend/internal/adapters/agent/opencode/opencode_test.go
#	backend/internal/adapters/agent/vibe/vibe.go
#	backend/internal/service/session/service.go
#	backend/internal/service/session/service_test.go
#	backend/internal/session_manager/manager.go
#	docs/agent/README.md
This commit is contained in:
whoisasx 2026-07-04 19:45:13 +05:30
commit 9e39df43cf
136 changed files with 7828 additions and 1395 deletions

View File

@ -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 |
---

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 ""
}

View File

@ -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, "")
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 }
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 }
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 {
@ -273,6 +276,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.

View File

@ -520,6 +520,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")

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 <agentSessionId>`. 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 <agentSessionId>`. 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)
if cfg.SystemPrompt != "" {
cmd = append(cmd, "-s", cfg.SystemPrompt)

View File

@ -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
@ -255,7 +279,6 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
}
want := []string{
"cline",
"--json",
"--auto-approve", "true",
"-s", "restore instructions",
"--id", "session-123",

View File

@ -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)
}

View File

@ -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 {
@ -151,10 +154,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
@ -208,6 +238,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 {
@ -222,6 +253,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
@ -333,7 +372,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()
}

View File

@ -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

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
// <prompt>` 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 <agentSessionId>`; the
// native session id (a UUID under ~/.copilot/session-state/) is captured by the
// SessionStart hook AO installs (see hooks.go).
@ -75,13 +75,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:
//
// [env COPILOT_CUSTOM_INSTRUCTIONS_DIRS=<ao-dir>[,<existing>]] copilot [permission flags] [-p <prompt>]
// [env COPILOT_CUSTOM_INSTRUCTIONS_DIRS=<ao-dir>[,<existing>]] copilot [permission flags]
//
// The prompt is delivered with `-p`, which runs the prompt in non-interactive
// mode and exits when done. Copilot CLI custom instructions are directory-based,
// so AO writes an AGENTS.md into the AO prompt artifact directory and points
// 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 custom instructions are directory-based, so AO writes an
// AGENTS.md into the AO prompt artifact directory and points
// COPILOT_CUSTOM_INSTRUCTIONS_DIRS at it when standing instructions are present.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.copilotBinary(ctx)
@ -97,20 +98,16 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
cmd = append(cmd, 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
@ -205,6 +202,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
}
@ -217,11 +217,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 {
@ -232,6 +236,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()

View File

@ -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)
}
@ -55,7 +56,7 @@ func TestGetLaunchCommandAddsCustomInstructionsDir(t *testing.T) {
}
dir := filepath.Dir(promptFile)
want := []string{"env", "COPILOT_CUSTOM_INSTRUCTIONS_DIRS=" + dir + ",/user/instructions", "copilot", "--allow-all", "-p", "-fix this"}
want := []string{"env", "COPILOT_CUSTOM_INSTRUCTIONS_DIRS=" + dir + ",/user/instructions", "copilot", "--allow-all"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
@ -152,7 +153,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)
}
}
@ -169,6 +170,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"}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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=<mode>] goose run [--system <text>] [-t <prompt>]
// [env GOOSE_MODE=<mode>] goose run [--system <text>] -t <prompt>
//
// The prompt is delivered in-command via `-t`. A non-default permission mode is
// rendered as an `env GOOSE_MODE=<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

View File

@ -71,6 +71,22 @@ func TestGetLaunchCommandPrefersInlineSystemPrompt(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())

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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"
)
@ -209,6 +210,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()
@ -555,6 +594,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 {

View File

@ -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)
}

View File

@ -17,6 +17,7 @@ package opencode
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
@ -25,10 +26,13 @@ import (
"runtime"
"strings"
"sync"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
_ "modernc.org/sqlite" // register sqlite driver for opencode session metadata probes
)
const (
@ -58,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 {
@ -175,6 +180,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:
@ -277,9 +463,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
@ -303,7 +487,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 != "" {
@ -330,7 +514,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) {

View File

@ -2,7 +2,9 @@ package opencode
import (
"context"
"database/sql"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
@ -12,6 +14,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"}
promptFile := filepath.Join(t.TempDir(), "system.md")

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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

View File

@ -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()

View File

@ -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
}

View File

@ -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)
}

View File

@ -80,13 +80,15 @@ 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 <profile-or-ao-agent>] [--auto-approve] -p <prompt>
// vibe --trust --output text [--workdir <path>] [--agent <profile-or-ao-agent>] [--auto-approve] -p <prompt>
//
// 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, so AO writes a workspace-local custom agent and selects it with --agent
// when standing instructions are present.
// `--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, so
// AO writes a workspace-local custom agent and selects it with --agent when
// standing instructions are present.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
if err := ctx.Err(); err != nil {
return nil, err
@ -101,6 +103,7 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
return nil, err
}
cmd = []string{binary, "--trust", "--output", "text"}
appendWorkdirFlag(&cmd, cfg.WorkspacePath)
if agentName != "" {
cmd = append(cmd, "--agent", agentName)
appendCustomAgentApprovalFlags(&cmd, cfg.Permissions)
@ -149,6 +152,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
return nil, false, err
}
cmd = []string{binary, "--trust", "--output", "text"}
appendWorkdirFlag(&cmd, cfg.Session.WorkspacePath)
if agentName != "" {
cmd = append(cmd, "--agent", agentName)
appendCustomAgentApprovalFlags(&cmd, cfg.Permissions)
@ -168,6 +172,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.

View File

@ -42,6 +42,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 {
@ -55,14 +140,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)
}
@ -83,7 +169,7 @@ func TestGetLaunchCommandBuildsCustomAgentForSystemPrompt(t *testing.T) {
t.Fatal(err)
}
want := []string{"vibe", "--trust", "--output", "text", "--agent", "ao-system-prompt", "--auto-approve", "-p", "add a health check"}
want := []string{"vibe", "--trust", "--output", "text", "--workdir", workspace, "--agent", "ao-system-prompt", "--auto-approve", "-p", "add a health check"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
@ -117,7 +203,7 @@ func TestGetLaunchCommandCustomAgentAcceptEdits(t *testing.T) {
t.Fatal(err)
}
want := []string{"vibe", "--trust", "--output", "text", "--agent", "ao-system-prompt"}
want := []string{"vibe", "--trust", "--output", "text", "--workdir", workspace, "--agent", "ao-system-prompt"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
@ -192,7 +278,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,
})
@ -203,7 +290,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)
}
@ -229,7 +316,7 @@ func TestGetRestoreCommandReappliesSystemPromptAgent(t *testing.T) {
t.Fatal("ok=false, want true")
}
want := []string{"vibe", "--trust", "--output", "text", "--agent", "ao-system-prompt", "--auto-approve", "--resume", "abcd1234"}
want := []string{"vibe", "--trust", "--output", "text", "--workdir", workspace, "--agent", "ao-system-prompt", "--auto-approve", "--resume", "abcd1234"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("cmd = %#v, want %#v", cmd, want)
}

View File

@ -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)

View File

@ -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"
@ -133,7 +135,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,

View File

@ -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)

View File

@ -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:
@ -1982,6 +2075,8 @@ components:
type: string
name:
type: string
orchestratorAgent:
type: string
path:
type: string
resolveError:
@ -2593,6 +2688,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

View File

@ -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 {

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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."`

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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())

View File

@ -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},
}
}

View File

@ -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
})
}

View File

@ -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
}
@ -89,11 +104,12 @@ func (m *Service) List(ctx context.Context) ([]Summary, error) {
out := make([]Summary, 0, len(projects))
for _, row := range projects {
out = append(out, Summary{
ID: domain.ProjectID(row.ID),
Name: displayName(row),
Path: row.Path,
Kind: row.Kind.WithDefault(),
SessionPrefix: resolveSessionPrefix(row),
ID: domain.ProjectID(row.ID),
Name: displayName(row),
Path: row.Path,
Kind: row.Kind.WithDefault(),
SessionPrefix: resolveSessionPrefix(row),
OrchestratorAgent: row.Config.Orchestrator.Harness,
})
}
return out, nil
@ -111,7 +127,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 +190,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 +205,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 +218,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 +240,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 +302,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 +381,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,14 +389,20 @@ 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
p.Config = &cfg
}
p.Config = projectConfigPtr(row.Config)
return p
}
func projectConfigPtr(projectConfig domain.ProjectConfig) *domain.ProjectConfig {
if projectConfig.IsZero() {
return nil
}
cfg := projectConfig
return &cfg
}
func displayName(row domain.ProjectRecord) string {
if strings.TrimSpace(row.DisplayName) != "" {
return row.DisplayName

View File

@ -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)
@ -412,6 +441,32 @@ func TestManager_SetConfig(t *testing.T) {
wantCode(t, err, "PROJECT_NOT_FOUND")
}
func TestManager_ListIncludesOnlySummarySafeProjectConfig(t *testing.T) {
ctx := context.Background()
m := newManager(t)
repo := gitRepo(t)
cfg := domain.ProjectConfig{
DefaultBranch: "develop",
Env: map[string]string{"GITHUB_TOKEN": "secret"},
Orchestrator: domain.RoleOverride{Harness: domain.HarnessCodex},
}
if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao"), Config: &cfg}); err != nil {
t.Fatalf("Add: %v", err)
}
list, err := m.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("List len = %d, want 1", len(list))
}
if list[0].OrchestratorAgent != domain.HarnessCodex {
t.Fatalf("summary orchestrator agent = %q, want codex", list[0].OrchestratorAgent)
}
}
func TestManager_ReaddAfterRemove(t *testing.T) {
ctx := context.Background()
m := newManager(t)

View File

@ -4,12 +4,13 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// Summary is the row shape returned by GET /api/v1/projects.
type Summary struct {
ID domain.ProjectID `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Kind domain.ProjectKind `json:"kind"`
SessionPrefix string `json:"sessionPrefix"`
ResolveError string `json:"resolveError,omitempty"`
ID domain.ProjectID `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Kind domain.ProjectKind `json:"kind"`
SessionPrefix string `json:"sessionPrefix"`
OrchestratorAgent domain.AgentHarness `json:"orchestratorAgent,omitempty"`
ResolveError string `json:"resolveError,omitempty"`
}
// Project is the full read-model returned by GET /api/v1/projects/{id}.

View File

@ -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)

View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
@ -45,6 +46,7 @@ type commander interface {
Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error)
Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error)
Kill(ctx context.Context, id domain.SessionID) (bool, error)
RetireForReplacement(ctx context.Context, id domain.SessionID) error
Send(ctx context.Context, id domain.SessionID, message string) error
Cleanup(ctx context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error)
RollbackSpawn(ctx context.Context, id domain.SessionID) (deleted, killed bool, err error)
@ -81,13 +83,15 @@ type scmProvider interface {
// session operations to the internal sessionmanager.Manager and owns read-model
// assembly, including user-facing display status derivation.
type Service struct {
manager commander
store Store
prClaimer ports.PRClaimer
scm scmProvider
tracker ports.Tracker
clock func() time.Time
telemetry ports.EventSink
manager commander
store Store
prClaimer ports.PRClaimer
scm scmProvider
tracker ports.Tracker
clock func() time.Time
telemetry ports.EventSink
orchestratorLocksMu sync.Mutex
orchestratorLocks map[domain.ProjectID]*sync.Mutex
// signalCapable reports whether a harness has a hook pipeline that can
// deliver activity signals at all. Only capable harnesses are eligible for
// the no_signal downgrade: a hook-less harness staying silent forever is
@ -266,6 +270,13 @@ func (s *Service) emitSpawnFailed(cfg ports.SpawnConfig, err error, durationMs i
// active orchestrator already exists it is returned as-is. A business rule that
// belongs here, not in the HTTP controller.
func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) {
unlock := s.lockOrchestratorProject(projectID)
defer unlock()
project, err := s.requireProject(ctx, projectID)
if err != nil {
return domain.Session{}, err
}
active := true
if clean {
existing, err := s.List(ctx, ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true})
@ -273,8 +284,9 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec
return domain.Session{}, err
}
for _, orch := range existing {
if _, err := s.Kill(ctx, orch.ID); err != nil {
return domain.Session{}, err
_ = s.sendRetireNotice(ctx, orch.ID)
if err := s.manager.RetireForReplacement(ctx, orch.ID); err != nil {
return domain.Session{}, toAPIError(err)
}
}
} else {
@ -284,10 +296,90 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec
return domain.Session{}, err
}
if len(existing) > 0 {
return existing[0], nil
return newestSession(existing), nil
}
}
return s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator})
sess, err := s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator})
if err != nil {
return domain.Session{}, err
}
if err := verifyOrchestratorReplacement(project, sess); err != nil {
return domain.Session{}, err
}
return sess, nil
}
const orchestratorRetireNotice = "AO is replacing this project orchestrator. Stop coordinating new work now; a fresh orchestrator will take over on the canonical branch."
func (s *Service) sendRetireNotice(ctx context.Context, id domain.SessionID) error {
if err := s.manager.Send(ctx, id, orchestratorRetireNotice); err != nil {
return fmt.Errorf("send retire notice to %s: %w", id, err)
}
return nil
}
func verifyOrchestratorReplacement(project domain.ProjectRecord, sess domain.Session) error {
if sess.IsTerminated {
return fmt.Errorf("orchestrator replacement verification failed: new session %s is terminated", sess.ID)
}
if sess.Kind != domain.KindOrchestrator {
return fmt.Errorf("orchestrator replacement verification failed: new session %s has kind %q", sess.ID, sess.Kind)
}
if expected := project.Config.Orchestrator.Harness; expected != "" && sess.Harness != expected {
return fmt.Errorf("orchestrator replacement verification failed: new session %s uses harness %q, want %q", sess.ID, sess.Harness, expected)
}
expectedBranch := "ao/" + serviceSessionPrefix(project) + "-orchestrator"
if sess.Metadata.Branch != "" && sess.Metadata.Branch != expectedBranch {
return fmt.Errorf("orchestrator replacement verification failed: new session %s uses branch %q, want %q", sess.ID, sess.Metadata.Branch, expectedBranch)
}
return nil
}
func serviceSessionPrefix(project domain.ProjectRecord) string {
if p := strings.TrimSpace(project.Config.SessionPrefix); p != "" {
return p
}
id := project.ID
if len(id) <= 12 {
return id
}
return id[:12]
}
func newestSession(sessions []domain.Session) domain.Session {
newest := sessions[0]
for _, sess := range sessions[1:] {
if sessionNewer(sess.SessionRecord, newest.SessionRecord) {
newest = sess
}
}
return newest
}
func sessionNewer(a, b domain.SessionRecord) bool {
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.After(b.CreatedAt)
}
if !a.UpdatedAt.Equal(b.UpdatedAt) {
return a.UpdatedAt.After(b.UpdatedAt)
}
return string(a.ID) > string(b.ID)
}
func (s *Service) lockOrchestratorProject(projectID domain.ProjectID) func() {
s.orchestratorLocksMu.Lock()
if s.orchestratorLocks == nil {
s.orchestratorLocks = make(map[domain.ProjectID]*sync.Mutex)
}
mu := s.orchestratorLocks[projectID]
if mu == nil {
mu = &sync.Mutex{}
s.orchestratorLocks[projectID] = mu
}
s.orchestratorLocksMu.Unlock()
mu.Lock()
return mu.Unlock
}
// Restore relaunches a terminated session and returns the API-facing read model.

View File

@ -202,10 +202,15 @@ func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) {
// clean-orchestrator ordering without wiring a real session engine.
type fakeCommander struct {
killed []domain.SessionID
retired []domain.SessionID
sent []domain.SessionID
cleanupProjects []domain.ProjectID
killErr error
retireErr error
sendErr error
cleanupErr error
spawnErr error
spawnRecord domain.SessionRecord
spawned bool
spawnedCfg ports.SpawnConfig
killsAtSpawn int
@ -217,7 +222,10 @@ func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.
}
f.spawned = true
f.spawnedCfg = cfg
f.killsAtSpawn = len(f.killed)
f.killsAtSpawn = len(f.retired)
if f.spawnRecord.ID != "" {
return f.spawnRecord, nil
}
return domain.SessionRecord{ID: "mer-9", ProjectID: cfg.ProjectID, Kind: cfg.Kind, Harness: cfg.Harness}, nil
}
func (f *fakeCommander) Restore(context.Context, domain.SessionID) (domain.SessionRecord, error) {
@ -230,7 +238,20 @@ func (f *fakeCommander) Kill(_ context.Context, id domain.SessionID) (bool, erro
f.killed = append(f.killed, id)
return true, nil
}
func (f *fakeCommander) Send(context.Context, domain.SessionID, string) error { return nil }
func (f *fakeCommander) RetireForReplacement(_ context.Context, id domain.SessionID) error {
if f.retireErr != nil {
return f.retireErr
}
f.retired = append(f.retired, id)
return nil
}
func (f *fakeCommander) Send(_ context.Context, id domain.SessionID, _ string) error {
if f.sendErr != nil {
return f.sendErr
}
f.sent = append(f.sent, id)
return nil
}
func (f *fakeCommander) Cleanup(_ context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) {
f.cleanupProjects = append(f.cleanupProjects, project)
if f.cleanupErr != nil {
@ -296,7 +317,7 @@ func TestTeardownProjectStopsOnKillError(t *testing.T) {
}
}
func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) {
func TestSpawnOrchestratorCleanRetiresActiveOrchestratorsBeforeSpawn(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
// Two active orchestrators plus an unrelated worker and a terminated
@ -313,11 +334,35 @@ func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T)
t.Fatalf("SpawnOrchestrator: %v", err)
}
if len(fc.killed) != 2 {
t.Fatalf("killed = %v, want the two active orchestrators", fc.killed)
if len(fc.retired) != 2 {
t.Fatalf("retired = %v, want the two active orchestrators", fc.retired)
}
if len(fc.sent) != 2 {
t.Fatalf("retire notices = %v, want the two active orchestrators", fc.sent)
}
if !fc.spawned || fc.killsAtSpawn != 2 {
t.Fatalf("spawn must run after both kills: spawned=%v killsAtSpawn=%d", fc.spawned, fc.killsAtSpawn)
t.Fatalf("spawn must run after both retirements: spawned=%v retirementsAtSpawn=%d", fc.spawned, fc.killsAtSpawn)
}
if len(fc.killed) != 0 {
t.Fatalf("interactive Kill must not be used for replacement: killed=%v", fc.killed)
}
}
func TestSpawnOrchestratorCleanContinuesWhenRetireNoticeFails(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
fc := &fakeCommander{sendErr: errors.New("pane closed")}
svc := &Service{manager: fc, store: st}
if _, err := svc.SpawnOrchestrator(context.Background(), "mer", true); err != nil {
t.Fatalf("SpawnOrchestrator: %v", err)
}
if len(fc.retired) != 1 || fc.retired[0] != "mer-1" {
t.Fatalf("retired = %v, want mer-1 despite retire notice failure", fc.retired)
}
if !fc.spawned {
t.Fatal("replacement should still spawn when retire notice delivery fails")
}
}
@ -778,6 +823,29 @@ func TestSpawnOrchestratorNoCleanSpawnsWhenNoneExists(t *testing.T) {
}
}
func TestSpawnOrchestratorVerifiesReplacementHarness(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{
ID: "mer",
Config: domain.ProjectConfig{Orchestrator: domain.RoleOverride{Harness: domain.HarnessCodex}},
}
fc := &fakeCommander{
spawnRecord: domain.SessionRecord{
ID: "mer-9",
ProjectID: "mer",
Kind: domain.KindOrchestrator,
Harness: domain.HarnessClaudeCode,
Metadata: domain.SessionMetadata{Branch: "ao/mer-orchestrator"},
},
}
svc := &Service{manager: fc, store: st}
_, err := svc.SpawnOrchestrator(context.Background(), "mer", false)
if err == nil || !strings.Contains(err.Error(), `uses harness "claude-code", want "codex"`) {
t.Fatalf("SpawnOrchestrator err = %v, want harness verification failure", err)
}
}
type fakePRClaimer struct {
out errorFreeClaimOutcome
err error

Some files were not shown because too many files have changed in this diff Show More