feat: add agent catalog/auth API and safer orchestrator switching (#2309)

* feat: add agent catalog API and integrate with project settings

- Implemented AgentsController to handle /agents endpoint, returning a list of supported and installed agents.
- Created agent inventory service to manage agent data and detect installed agents.
- Updated ProjectSettingsForm to fetch and display agent information, including installed and supported agents.
- Enhanced error handling for agent detection and orchestrator restarts.
- Added tests for agent catalog and service to ensure correct functionality and error handling.

* Implement agent authorization status checks and update frontend to reflect changes

- Added `AuthStatus` method to various agent plugins to check authorization status using CLI probes.
- Introduced `authprobe` package to handle common CLI command checks for agent authorization.
- Updated backend tests to include scenarios for authorized and unauthorized agents.
- Modified frontend API schema to include `authorized` counts and `authStatus` for agents.
- Enhanced `ProjectSettingsForm` to display authorized agents and their statuses, including prompts for login when necessary.
- Adjusted agent selection logic to prioritize authorized agents and provide feedback for unauthorized or uninstalled agents.

* fix: simplify orchestrator replacement retry flow

* refactor:  cache agent catalog ,remove AgentCounts schema and related references from API and frontend

* fix: clarify orchestrator replacement recovery state

* feat: enhance project settings and agent management

- Updated NewTaskDialog tests to increase timeout for async operations.
- Modified ProjectSettingsForm tests to improve agent handling and validation messages.
- Refactored ProjectSettingsForm component to streamline agent selection and validation logic.
- Introduced new agent service to manage agent inventory and authentication status.
- Improved Sidebar tests to ensure proper agent options are loaded and handled.
- Enhanced SessionsBoard component by removing unused imports and optimizing state management.
- Fixed Select component styling for better consistency in UI.
- Added error handling for AO daemon readiness in ShellLayout.

* chore: format with prettier [skip ci]

* feat: add AuthStatus method documentation and improve error handling in session manager

* feat: enhance agent authentication and configuration management

- Implement local authentication status checks for PI, Qwen, and Vibe agents.
- Introduce JSON-based authentication status retrieval for PI agents.
- Add environment variable checks for Qwen agents and improve settings file parsing.
- Enhance Vibe agent authentication with support for environment variables and session logs.
- Update agent service to handle asynchronous probing for installed and authorized agents.
- Modify session manager to support prompt delivery strategies based on agent capabilities.
- Improve frontend agent selection UI with loading states and error handling.
- Add tests for new authentication logic and session management features.

* chore: format with prettier [skip ci]

* test: fix session manager fake after rebase

* feat: enhance agent authentication status checks

- Implement local authentication status checks for the Devin, Droid, Kiro, and other agents.
- Add support for reading credentials from specific configuration files and environment variables.
- Introduce new tests for various agents to ensure proper authentication status reporting.
- Refactor existing authentication logic to improve clarity and maintainability.
- Remove deprecated agent setup warnings from the SessionsBoard component in the frontend.

* feat: clear environment variables in auth status tests for Aider and OpenCode

* feat: enhance error handling in authentication status functions and update component props

* feat: integrate fallback agents in RequiredAgentField and streamline props handling

* fix: refactor Sidebar test imports and parameters for clarity

* refactor: remove orchestrator retirement logic and related tests

- Deleted the RetireOrchestrator function and its associated error handling.
- Removed tests related to orchestrator retirement and state management.
- Simplified ProjectSettingsForm by eliminating orchestrator restart logic and related UI elements.
- Updated API client mocks to reflect the removal of orchestrator-related functionality.

* feat: enhance agent management and error handling

- Added agent refresh functionality in ProjectSettingsForm with UI updates for agent availability.
- Implemented `refreshAgents` API call to fetch the latest agent catalog.
- Updated agent selection logic to disable unavailable agents and show appropriate error messages.
- Enhanced error handling in `apiErrorMessage` to include daemon error codes alongside messages.
- Created new test cases for agent availability and error handling in Sidebar component.
- Introduced `ResolveBinary` method for multiple agent adapters to standardize binary resolution.
- Added new agent adapter files for various agents (e.g., Aider, Claude Code, etc.) to support binary resolution.

* chore: format with prettier [skip ci]

* fix: satisfy backend lint checks

* refactor: clean up authentication logic and improve error handling across agents

* chore: format with prettier [skip ci]

* feat(authprobe): enhance status classification for authentication outputs and add tests for explicit false/true keys
chore(docs): update README to remove outdated agent adapter contract references
fix(components): improve agent selection logic to handle unknown auth status and update related tests

* chore: format with prettier [skip ci]

* refactor: remove shell environment authentication logic and update related tests

* feat(tests): integrate QueryClient for agent data in CreateProjectAgentSheet tests

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
NIKHIL ACHALE 2026-07-04 10:59:34 +05:30 committed by GitHub
parent d18ea87f57
commit a639e2025c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
121 changed files with 6685 additions and 1327 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 {
@ -269,6 +272,110 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
return info, true, nil
}
// AuthStatus checks Claude Code's local authentication state without starting a
// session.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.claudeBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := claudeLocalAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(probeCtx, binary, "auth", "status").CombinedOutput()
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, probeCtx.Err()
}
if status, ok := claudeAuthStatusFromOutput(out); ok {
return status, nil
}
if err != nil {
return ports.AgentAuthStatusUnauthorized, nil
}
return ports.AgentAuthStatusUnknown, nil
}
func claudeAuthStatusFromOutput(out []byte) (ports.AgentAuthStatus, bool) {
start := bytes.IndexByte(out, '{')
end := bytes.LastIndexByte(out, '}')
if start < 0 || end < start {
return ports.AgentAuthStatusUnknown, false
}
var status struct {
LoggedIn bool `json:"loggedIn"`
}
if json.Unmarshal(out[start:end+1], &status) != nil {
return ports.AgentAuthStatusUnknown, false
}
if status.LoggedIn {
return ports.AgentAuthStatusAuthorized, true
}
return ports.AgentAuthStatusUnauthorized, true
}
func claudeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
cfgPath, err := claudeConfigPath()
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
return claudeConfigAuthStatus(cfgPath)
}
func claudeConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(string(data)) == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
var root map[string]json.RawMessage
if err := json.Unmarshal(data, &root); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
var hasSubscription bool
if raw := root["hasAvailableSubscription"]; len(raw) > 0 {
_ = json.Unmarshal(raw, &hasSubscription)
}
var userID string
if raw := root["userID"]; len(raw) > 0 {
_ = json.Unmarshal(raw, &userID)
}
if strings.TrimSpace(userID) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
var oauthAccount map[string]any
if raw := root["oauthAccount"]; len(raw) > 0 {
if err := json.Unmarshal(raw, &oauthAccount); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
}
if len(oauthAccount) == 0 {
return ports.AgentAuthStatusUnknown, false, nil
}
if hasSubscription {
return ports.AgentAuthStatusAuthorized, true, nil
}
if accountUUID, ok := oauthAccount["accountUuid"].(string); ok && strings.TrimSpace(accountUUID) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}
// claudeSessionUUID maps an AO session id onto a stable Claude Code
// session UUID via UUIDv5 over a fixed namespace, so the same AO session
// always resolves to the same Claude session.

View File

@ -490,6 +490,97 @@ func TestManifestID(t *testing.T) {
}
}
func TestClaudeConfigAuthStatusAuthorizedWithOAuthSubscription(t *testing.T) {
path := filepath.Join(t.TempDir(), ".claude.json")
content := `{
"hasAvailableSubscription": true,
"oauthAccount": {
"accountUuid": "account-1",
"subscriptionCreatedAt": "2026-01-01T00:00:00Z"
}
}`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := claudeConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClaudeConfigAuthStatusAuthorizedWithOAuthAccount(t *testing.T) {
path := filepath.Join(t.TempDir(), ".claude.json")
content := `{"oauthAccount":{"accountUuid":"account-1"}}`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := claudeConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClaudeConfigAuthStatusAuthorizedWithUserID(t *testing.T) {
path := filepath.Join(t.TempDir(), ".claude.json")
if err := os.WriteFile(path, []byte(`{"userID":"user-1"}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := claudeConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClaudeConfigAuthStatusUnknownWithoutOAuthIdentity(t *testing.T) {
path := filepath.Join(t.TempDir(), ".claude.json")
content := `{"oauthAccount":{}}`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := claudeConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if ok || status != ports.AgentAuthStatusUnknown {
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
}
}
func TestClaudeAuthStatusFromOutputAuthorizedWithCleanJSON(t *testing.T) {
status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":true,"authMethod":"oauth_token"}`))
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClaudeAuthStatusFromOutputAuthorizedWithPrefixedWarning(t *testing.T) {
output := []byte("warning: ignored config line\n{\"loggedIn\":true,\"authMethod\":\"oauth_token\"}\n")
status, ok := claudeAuthStatusFromOutput(output)
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClaudeAuthStatusFromOutputUnauthorized(t *testing.T) {
status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":false}`))
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}
func TestEnsureWorkspaceTrustedCreatesEntry(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, ".claude.json")

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)
cmd = append(cmd, "--id", agentSessionID)
return cmd, true, nil

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
@ -254,7 +278,6 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
}
want := []string{
"cline",
"--json",
"--auto-approve", "true",
"--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 {
@ -146,10 +149,37 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
return info, true, nil
}
// AuthStatus checks Codex's local login state without making a model call.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.codexBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(probeCtx, binary, "login", "status").CombinedOutput()
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, probeCtx.Err()
}
text := strings.ToLower(string(out))
if strings.Contains(text, "not logged in") || strings.Contains(text, "logged out") {
return ports.AgentAuthStatusUnauthorized, nil
}
if strings.Contains(text, "logged in") {
return ports.AgentAuthStatusAuthorized, nil
}
if err != nil {
return ports.AgentAuthStatusUnauthorized, nil
}
return ports.AgentAuthStatusUnknown, nil
}
// ResolveCodexBinary returns the path to the codex binary on this machine,
// searching PATH then a handful of well-known install locations
// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback
// so callers see a clear "command not found" rather than an empty argv.
// (Homebrew, Cargo, npm global, NVM). Returns "codex" as a last-ditch
// fallback so callers see a clear "command not found" rather than an empty
// argv.
func ResolveCodexBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
@ -203,6 +233,7 @@ func ResolveCodexBinary(ctx context.Context) (string, error) {
filepath.Join(home, ".cargo", "bin", "codex"),
filepath.Join(home, ".npm", "bin", "codex"),
)
candidates = append(candidates, nvmNodeBinCandidates(home, "codex")...)
}
for _, candidate := range candidates {
@ -217,6 +248,14 @@ func ResolveCodexBinary(ctx context.Context) (string, error) {
return "", fmt.Errorf("codex: %w", ports.ErrAgentBinaryNotFound)
}
func nvmNodeBinCandidates(home, binary string) []string {
matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin", binary))
if err != nil || len(matches) == 0 {
return nil
}
sort.Sort(sort.Reverse(sort.StringSlice(matches)))
return matches
}
func resolveNativeWindowsCodex(path string) string {
if runtime.GOOS != "windows" || !strings.EqualFold(filepath.Ext(path), ".cmd") {
return path
@ -328,7 +367,7 @@ func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
}
}
func fileExists(path string) bool {
var fileExists = func(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

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).
@ -74,13 +74,14 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
return ports.ConfigSpec{}, nil
}
// GetLaunchCommand builds the argv to start a new headless Copilot session:
// GetLaunchCommand builds the argv to start a new interactive Copilot session:
//
// copilot [permission flags] [-p <prompt>]
// copilot [permission flags]
//
// The prompt is delivered with `-p`, which runs the prompt in non-interactive
// mode and exits when done. Copilot CLI does not have a documented
// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored.
// The prompt is delivered after the process starts; using `-p` runs Copilot in
// programmatic mode and exits when done, which leaves AO's terminal pane blank
// or dead. Copilot CLI does not have a documented system-prompt-injection flag,
// so SystemPrompt/SystemPromptFile are ignored.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.copilotBinary(ctx)
if err != nil {
@ -90,20 +91,16 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
cmd = []string{binary}
appendApprovalFlags(&cmd, cfg.Permissions)
if cfg.Prompt != "" {
cmd = append(cmd, "-p", cfg.Prompt)
}
return cmd, nil
}
// GetPromptDeliveryStrategy reports that Copilot receives its prompt in the
// launch command itself (via `-p`).
// GetPromptDeliveryStrategy reports that Copilot receives its prompt after the
// interactive process starts.
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
if err := ctx.Err(); err != nil {
return "", err
}
return ports.PromptDeliveryInCommand, nil
return ports.PromptDeliveryAfterStart, nil
}
// GetRestoreCommand rebuilds the argv that continues an existing Copilot
@ -194,6 +191,9 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) {
}
if path, err := exec.LookPath("copilot"); err == nil && path != "" {
if native := copilotNativeBinaryForLoader(path); native != "" {
return native, nil
}
return path, nil
}
@ -206,11 +206,15 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) {
filepath.Join(home, ".copilot", "bin", "copilot"),
filepath.Join(home, ".npm", "bin", "copilot"),
filepath.Join(home, ".local", "bin", "copilot"),
filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage", "github.copilot-chat", "copilotCli", "copilot"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
if native := copilotNativeBinaryForLoader(candidate); native != "" {
return native, nil
}
return candidate, nil
}
if err := ctx.Err(); err != nil {
@ -221,6 +225,25 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) {
return "", fmt.Errorf("copilot: %w", ports.ErrAgentBinaryNotFound)
}
func copilotNativeBinaryForLoader(path string) string {
if path == "" || runtime.GOOS == "windows" {
return ""
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil || filepath.Base(resolved) != "npm-loader.js" {
return ""
}
platform := runtime.GOOS
if platform == "darwin" {
platform = "darwin"
}
native := filepath.Join(filepath.Dir(resolved), "node_modules", ".bin", "copilot-"+platform+"-"+runtime.GOARCH)
if fileExists(native) {
return native
}
return ""
}
func (p *Plugin) copilotBinary(ctx context.Context) (string, error) {
p.binaryMu.Lock()
defer p.binaryMu.Unlock()

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)
}
@ -123,7 +124,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got != ports.PromptDeliveryInCommand {
if got != ports.PromptDeliveryAfterStart {
t.Fatalf("unexpected strategy: %q", got)
}
}
@ -140,6 +141,115 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
}
}
func TestCopilotNativeBinaryForNpmLoader(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("npm loader native binary naming is covered on Unix-like platforms")
}
dir := t.TempDir()
packageDir := filepath.Join(dir, "lib", "node_modules", "@github", "copilot")
binDir := filepath.Join(dir, "bin")
if err := os.MkdirAll(filepath.Join(packageDir, "node_modules", ".bin"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
loader := filepath.Join(packageDir, "npm-loader.js")
if err := os.WriteFile(loader, []byte("#!/usr/bin/env node\n"), 0o755); err != nil {
t.Fatal(err)
}
native := filepath.Join(packageDir, "node_modules", ".bin", "copilot-"+runtime.GOOS+"-"+runtime.GOARCH)
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(binDir, "copilot")
if err := os.Symlink(loader, link); err != nil {
t.Fatal(err)
}
want, err := filepath.EvalSymlinks(native)
if err != nil {
t.Fatal(err)
}
got, err := filepath.EvalSymlinks(copilotNativeBinaryForLoader(link))
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("native binary = %q, want %q", got, want)
}
}
func TestAuthStatusAuthorizedFromEnv(t *testing.T) {
clearCopilotAuthEnv(t)
t.Setenv("GH_TOKEN", "github_pat_test")
plugin := &Plugin{resolvedBinary: "copilot"}
got, err := plugin.AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestCopilotConfigAuthStatusAuthorizedWithPlainTextToken(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{"authToken":"token"}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := copilotConfigAuthStatus(configPath)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestCopilotConfigAuthStatusUnauthorizedWithEmptyConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := copilotConfigAuthStatus(configPath)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}
func TestCopilotSessionStateAuthStatusAuthorizedWithModelEvent(t *testing.T) {
dir := t.TempDir()
sessionDir := filepath.Join(dir, "session-1")
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sessionDir, "events.jsonl"), []byte(`{"type":"tool.execution_complete","data":{"model":"claude-sonnet-4.5"}}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := copilotSessionStateAuthStatus(context.Background(), dir)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func clearCopilotAuthEnv(t *testing.T) {
t.Helper()
for _, name := range copilotTokenEnvVars {
t.Setenv(name, "")
}
}
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
plugin := &Plugin{resolvedBinary: "copilot"}

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)
if cfg.Prompt == "" {
cmd = append(cmd, "--interactive")
}
return cmd, nil

View File

@ -71,6 +71,22 @@ func TestGetLaunchCommandSystemPromptFileInlined(t *testing.T) {
}
}
func TestGetLaunchCommandPromptlessLaunchStaysInteractive(t *testing.T) {
plugin := &Plugin{resolvedBinary: "goose"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
SystemPrompt: "coordinate this project",
})
if err != nil {
t.Fatal(err)
}
want := []string{"goose", "run", "--system", "coordinate this project", "-t", "", "--interactive"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
tests := []struct {
name string
@ -148,6 +164,73 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
}
}
func TestAuthStatusAuthorizedFromEnv(t *testing.T) {
clearGooseAuthEnv(t)
t.Setenv("OPENROUTER_API_KEY", "test-key")
plugin := &Plugin{resolvedBinary: "goose"}
got, err := plugin.AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestAuthStatusAuthorizedFromGooseConfig(t *testing.T) {
clearGooseAuthEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
configPath := filepath.Join(home, ".config", "goose", "config.yaml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configPath, []byte("providers:\n openrouter:\n configured: true\n model: anthropic/claude-sonnet-4\n"), 0o600); err != nil {
t.Fatal(err)
}
plugin := &Plugin{resolvedBinary: "goose"}
got, err := plugin.AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestAuthStatusUnauthorizedFromEmptyGooseConfig(t *testing.T) {
clearGooseAuthEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
configPath := filepath.Join(home, ".config", "goose", "config.yaml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
plugin := &Plugin{resolvedBinary: "goose"}
got, err := plugin.AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusUnauthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusUnauthorized)
}
}
func clearGooseAuthEnv(t *testing.T) {
t.Helper()
for _, name := range gooseAPIKeyEnvVars {
t.Setenv(name, "")
}
}
func TestContextCancellationIsHonored(t *testing.T) {
plugin := &Plugin{resolvedBinary: "goose"}
ctx, cancel := context.WithCancel(context.Background())

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"
)
@ -136,6 +137,44 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
}
}
func TestAuthStatusUsesKiroWhoami(t *testing.T) {
restore := stubKiroAuthRunner(t, func(_ context.Context, name string, arg ...string) ([]byte, error) {
if name != "kiro-cli" {
t.Fatalf("binary = %q, want kiro-cli", name)
}
if !reflect.DeepEqual(arg, []string{"whoami"}) {
t.Fatalf("args = %#v, want [whoami]", arg)
}
return []byte("Logged in with Google\nEmail: nicachale456@gmail.com\n"), nil
})
defer restore()
plugin := &Plugin{resolvedBinary: "kiro-cli"}
status, err := plugin.AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized)
}
}
func TestAuthStatusUnauthorizedFromKiroWhoami(t *testing.T) {
restore := stubKiroAuthRunner(t, func(_ context.Context, _ string, _ ...string) ([]byte, error) {
return []byte("Not logged in\n"), nil
})
defer restore()
plugin := &Plugin{resolvedBinary: "kiro-cli"}
status, err := plugin.AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnauthorized)
}
}
func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
workspace := t.TempDir()
@ -441,6 +480,13 @@ func containsSubsequence(values []string, needle []string) bool {
return false
}
func stubKiroAuthRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() {
t.Helper()
previous := authprobe.CmdRunner
authprobe.CmdRunner = runner
return func() { authprobe.CmdRunner = previous }
}
func countKiroHookCommand(entries []kiroHookEntry, command string) int {
count := 0
for _, entry := range entries {

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,15 +17,21 @@ package opencode
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
_ "modernc.org/sqlite" // register sqlite driver for opencode session metadata probes
)
const (
@ -55,6 +61,7 @@ func New() *Plugin {
var _ adapters.Adapter = (*Plugin)(nil)
var _ ports.Agent = (*Plugin)(nil)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// Manifest returns the adapter's static self-description.
func (p *Plugin) Manifest() adapters.Manifest {
@ -158,6 +165,187 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
return info, true, nil
}
// AuthStatus checks whether opencode has at least one configured provider
// credential.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.opencodeBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := opencodeLocalAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(probeCtx, binary, "auth", "list").CombinedOutput()
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, probeCtx.Err()
}
text := strings.ToLower(string(out))
if strings.Contains(text, "0 credentials") {
return ports.AgentAuthStatusUnauthorized, nil
}
if strings.Contains(text, "credential") && err == nil {
return ports.AgentAuthStatusAuthorized, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, nil
}
return ports.AgentAuthStatusUnknown, nil
}
var opencodeAPIKeyEnvVars = []string{
"OPENCODE_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"OPENROUTER_API_KEY",
"DEEPSEEK_API_KEY",
"GROQ_API_KEY",
"XAI_API_KEY",
"MISTRAL_API_KEY",
"COHERE_API_KEY",
}
func opencodeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
for _, name := range opencodeAPIKeyEnvVars {
if strings.TrimSpace(os.Getenv(name)) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
}
dataDir, ok := opencodeDataDir()
if !ok {
return ports.AgentAuthStatusUnknown, false, nil
}
jsonStatus, jsonOK, err := opencodeAuthJSONStatus(filepath.Join(dataDir, "auth.json"))
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if jsonOK && jsonStatus == ports.AgentAuthStatusAuthorized {
return jsonStatus, true, nil
}
if status, ok, err := opencodeDBAuthStatus(ctx, filepath.Join(dataDir, "opencode.db")); err != nil || ok {
return status, ok, err
}
if jsonOK {
return jsonStatus, true, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}
func opencodeDataDir() (string, bool) {
if dataDir := strings.TrimSpace(os.Getenv("OPENCODE_DATA_DIR")); dataDir != "" {
return dataDir, true
}
if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" {
return filepath.Join(dataHome, "opencode"), true
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "", false
}
return filepath.Join(home, ".local", "share", "opencode"), true
}
func opencodeAuthJSONStatus(path string) (ports.AgentAuthStatus, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(string(data)) == "" {
return ports.AgentAuthStatusUnauthorized, true, nil
}
var entries map[string]json.RawMessage
if err := json.Unmarshal(data, &entries); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if len(entries) == 0 {
return ports.AgentAuthStatusUnauthorized, true, nil
}
for key, value := range entries {
if strings.TrimSpace(key) == "" {
continue
}
trimmed := strings.TrimSpace(string(value))
if trimmed != "" && trimmed != "null" && trimmed != "{}" {
return ports.AgentAuthStatusAuthorized, true, nil
}
}
return ports.AgentAuthStatusUnauthorized, true, nil
}
func opencodeDBAuthStatus(ctx context.Context, path string) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
} else if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro&_pragma=busy_timeout(1000)")
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
defer func() {
_ = db.Close()
}()
authorized, known, err := opencodeDBHasAuthorizedAccount(ctx, db)
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if !known {
return ports.AgentAuthStatusUnknown, false, nil
}
if authorized {
return ports.AgentAuthStatusAuthorized, true, nil
}
return ports.AgentAuthStatusUnauthorized, true, nil
}
func opencodeDBHasAuthorizedAccount(ctx context.Context, db *sql.DB) (authorized, known bool, err error) {
for _, query := range []string{
`SELECT COUNT(*) FROM account_state WHERE active_account_id IS NOT NULL AND trim(active_account_id) != ''`,
`SELECT COUNT(*) FROM account WHERE trim(access_token) != ''`,
`SELECT COUNT(*) FROM control_account WHERE active = 1 AND trim(access_token) != ''`,
} {
count, err := opencodeDBCount(ctx, db, query)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "no such table") {
continue
}
return false, false, err
}
known = true
if count > 0 {
return true, true, nil
}
}
return false, known, nil
}
func opencodeDBCount(ctx context.Context, db *sql.DB, query string) (int, error) {
var count int
if err := db.QueryRowContext(ctx, query).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
// appendPermissionFlags maps AO's permission modes onto opencode's single
// approval flag. opencode exposes only --dangerously-skip-permissions (no
// graduated accept-edits/auto modes), so:
@ -185,9 +373,7 @@ func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
// ResolveOpenCodeBinary returns the path to the opencode binary on this machine,
// searching PATH then a handful of well-known install locations (the install
// script's ~/.opencode/bin, Homebrew, npm global). Returns "opencode" as a
// last-ditch fallback so callers see a clear "command not found" rather than an
// empty argv.
// script's ~/.opencode/bin, Homebrew, npm global).
func ResolveOpenCodeBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
@ -211,7 +397,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) {
return candidate, nil
}
}
return "opencode", nil
return "", fmt.Errorf("opencode: %w", ports.ErrAgentBinaryNotFound)
}
if path, err := exec.LookPath("opencode"); err == nil && path != "" {
@ -238,7 +424,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) {
}
}
return "opencode", nil
return "", fmt.Errorf("opencode: %w", ports.ErrAgentBinaryNotFound)
}
func (p *Plugin) opencodeBinary(ctx context.Context) (string, error) {

View File

@ -2,6 +2,8 @@ package opencode
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"reflect"
@ -11,6 +13,279 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestOpenCodeLocalAuthStatusAuthorizedWithEnv(t *testing.T) {
clearOpenCodeAuthEnv(t)
t.Setenv("ANTHROPIC_API_KEY", "sk-ant-test")
status, ok, err := opencodeLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestOpenCodeLocalAuthStatusAuthorizedWithAuthFile(t *testing.T) {
clearOpenCodeAuthEnv(t)
writeOpenCodeAuthFile(t, `{
"anthropic": {
"type": "api",
"key": "sk-ant-test"
}
}`)
status, ok, err := opencodeLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestOpenCodeLocalAuthStatusUnauthorizedWithEmptyAuthFile(t *testing.T) {
clearOpenCodeAuthEnv(t)
writeOpenCodeAuthFile(t, `{}`)
status, ok, err := opencodeLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}
func TestOpenCodeLocalAuthStatusAuthorizedWithActiveDBAccount(t *testing.T) {
clearOpenCodeAuthEnv(t)
dataDir := writeOpenCodeDB(t, func(db *sql.DB) {
if _, err := db.Exec(`
CREATE TABLE account (
id text PRIMARY KEY,
email text NOT NULL,
url text NOT NULL,
access_token text NOT NULL,
refresh_token text NOT NULL,
token_expiry integer,
time_created integer NOT NULL,
time_updated integer NOT NULL
);
CREATE TABLE account_state (
id integer PRIMARY KEY NOT NULL,
active_account_id text,
active_org_id text
);
INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated)
VALUES ('acct_1', 'user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1);
INSERT INTO account_state (id, active_account_id) VALUES (1, 'acct_1');
`); err != nil {
t.Fatal(err)
}
})
t.Setenv("OPENCODE_DATA_DIR", dataDir)
status, ok, err := opencodeLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestOpenCodeLocalAuthStatusDBAccountOverridesEmptyAuthFile(t *testing.T) {
clearOpenCodeAuthEnv(t)
dataDir := writeOpenCodeDB(t, func(db *sql.DB) {
if _, err := db.Exec(`
CREATE TABLE account (
id text PRIMARY KEY,
email text NOT NULL,
url text NOT NULL,
access_token text NOT NULL,
refresh_token text NOT NULL,
token_expiry integer,
time_created integer NOT NULL,
time_updated integer NOT NULL
);
INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated)
VALUES ('acct_1', 'user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1);
`); err != nil {
t.Fatal(err)
}
})
t.Setenv("OPENCODE_DATA_DIR", dataDir)
if err := os.WriteFile(filepath.Join(dataDir, "auth.json"), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := opencodeLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestOpenCodeLocalAuthStatusAuthorizedWithControlDBAccount(t *testing.T) {
clearOpenCodeAuthEnv(t)
dataHome := t.TempDir()
dataDir := filepath.Join(dataHome, "opencode")
writeOpenCodeDBAt(t, dataDir, func(db *sql.DB) {
if _, err := db.Exec(`
CREATE TABLE control_account (
email text NOT NULL,
url text NOT NULL,
access_token text NOT NULL,
refresh_token text NOT NULL,
token_expiry integer,
active integer NOT NULL,
time_created integer NOT NULL,
time_updated integer NOT NULL,
PRIMARY KEY(email, url)
);
INSERT INTO control_account (email, url, access_token, refresh_token, active, time_created, time_updated)
VALUES ('user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1, 1);
`); err != nil {
t.Fatal(err)
}
})
t.Setenv("XDG_DATA_HOME", dataHome)
status, ok, err := opencodeLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestOpenCodeLocalAuthStatusUnauthorizedWithEmptyDBAccounts(t *testing.T) {
clearOpenCodeAuthEnv(t)
dataDir := writeOpenCodeDB(t, func(db *sql.DB) {
if _, err := db.Exec(`
CREATE TABLE account (
id text PRIMARY KEY,
email text NOT NULL,
url text NOT NULL,
access_token text NOT NULL,
refresh_token text NOT NULL,
token_expiry integer,
time_created integer NOT NULL,
time_updated integer NOT NULL
);
CREATE TABLE account_state (
id integer PRIMARY KEY NOT NULL,
active_account_id text,
active_org_id text
);
CREATE TABLE control_account (
email text NOT NULL,
url text NOT NULL,
access_token text NOT NULL,
refresh_token text NOT NULL,
token_expiry integer,
active integer NOT NULL,
time_created integer NOT NULL,
time_updated integer NOT NULL,
PRIMARY KEY(email, url)
);
`); err != nil {
t.Fatal(err)
}
})
t.Setenv("OPENCODE_DATA_DIR", dataDir)
status, ok, err := opencodeLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}
func TestOpenCodeLocalAuthStatusUnknownWhenMissing(t *testing.T) {
clearOpenCodeAuthEnv(t)
t.Setenv("HOME", t.TempDir())
status, ok, err := opencodeLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if ok || status != ports.AgentAuthStatusUnknown {
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
}
}
func writeOpenCodeDB(t *testing.T, setup func(*sql.DB)) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
dataDir := filepath.Join(home, ".local", "share", "opencode")
writeOpenCodeDBAt(t, dataDir, setup)
return dataDir
}
func writeOpenCodeDBAt(t *testing.T, dataDir string, setup func(*sql.DB)) {
t.Helper()
if err := os.MkdirAll(dataDir, 0o700); err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(filepath.Join(dataDir, "opencode.db"))+"?mode=rwc")
if err != nil {
t.Fatal(err)
}
defer db.Close()
setup(db)
}
func writeOpenCodeAuthFile(t *testing.T, content string) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
authDir := filepath.Join(home, ".local", "share", "opencode")
if err := os.MkdirAll(authDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(authDir, "auth.json"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func clearOpenCodeAuthEnv(t *testing.T) {
t.Helper()
for _, name := range opencodeAPIKeyEnvVars {
t.Setenv(name, "")
}
t.Setenv("OPENCODE_DATA_DIR", "")
t.Setenv("XDG_DATA_HOME", "")
}
func TestResolveOpenCodeBinaryFallback(t *testing.T) {
bin, err := ResolveOpenCodeBinary(context.Background())
if err != nil {
if !errors.Is(err, ports.ErrAgentBinaryNotFound) {
t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err)
}
return
}
if bin == "" {
t.Fatal("ResolveOpenCodeBinary returned empty path with no error")
}
}
func TestResolveOpenCodeBinaryContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := ResolveOpenCodeBinary(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("ResolveOpenCodeBinary err = %v, want context.Canceled", err)
}
}
func TestGetLaunchCommandBuildsArgv(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}

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

@ -84,6 +84,7 @@ func Build() (*adapters.Registry, error) {
// value a session carries and the `--harness` flag users pass.
type HarnessAgent struct {
Harness domain.AgentHarness
Manifest adapters.Manifest
Agent ports.Agent
}
@ -100,6 +101,7 @@ func Harnessed() []HarnessAgent {
}
out = append(out, HarnessAgent{
Harness: domain.AgentHarness(a.Manifest().ID),
Manifest: a.Manifest(),
Agent: agent,
})
}

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

@ -78,12 +78,14 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
// GetLaunchCommand builds the argv to start a new non-interactive Vibe session:
//
// vibe --trust --output text [--agent <profile>] -p <prompt>
// vibe --trust --output text [--workdir <path>] [--agent <profile>] -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 (system prompts are config-driven), so SystemPrompt is not forwarded.
// `--output text` pins the output format. `--workdir` is passed explicitly
// because Vibe validates its own working directory in addition to the process
// cwd AO sets through the runtime. Vibe exposes no CLI system-prompt flag
// (system prompts are config-driven), so SystemPrompt is not forwarded.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
if err := ctx.Err(); err != nil {
return nil, err
@ -94,6 +96,7 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
}
cmd = []string{binary, "--trust", "--output", "text"}
appendWorkdirFlag(&cmd, cfg.WorkspacePath)
appendAgentFlags(&cmd, cfg.Permissions)
if cfg.Prompt != "" {
cmd = append(cmd, "-p", cfg.Prompt)
@ -134,6 +137,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
}
cmd = make([]string, 0, 8)
cmd = append(cmd, binary, "--trust", "--output", "text")
appendWorkdirFlag(&cmd, cfg.Session.WorkspacePath)
appendAgentFlags(&cmd, cfg.Permissions)
cmd = append(cmd, "--resume", agentSessionID)
return cmd, true, nil
@ -148,6 +152,12 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
return ports.SessionInfo{}, false, nil
}
func appendWorkdirFlag(cmd *[]string, workspacePath string) {
if workspacePath != "" {
*cmd = append(*cmd, "--workdir", workspacePath)
}
}
// appendAgentFlags maps AO permission modes onto Vibe's builtin `--agent`
// profiles. PermissionModeDefault (and the empty mode) emit no flag so Vibe
// resolves its starting agent from the user's `default_agent` config.

View File

@ -3,6 +3,8 @@ package vibe
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
@ -39,6 +41,91 @@ func TestGetConfigSpecEmpty(t *testing.T) {
}
}
func TestAuthStatusAuthorizedFromEnv(t *testing.T) {
clearVibeAuthEnv(t, vibeDefaultAPIKeyEnvVar, "VIBE_CODE_API_KEY")
t.Setenv(vibeDefaultAPIKeyEnvVar, "test-key")
p := &Plugin{resolvedBinary: "vibe"}
got, err := p.AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestVibeAPIKeyEnvVarsReadsConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configPath, []byte("[[providers]]\napi_key_env_var = \"CUSTOM_VIBE_KEY\"\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := vibeAPIKeyEnvVars(configPath)
if err != nil {
t.Fatal(err)
}
if !containsString(got, vibeDefaultAPIKeyEnvVar) || !containsString(got, "CUSTOM_VIBE_KEY") {
t.Fatalf("vibeAPIKeyEnvVars = %#v, want default and custom key", got)
}
}
func TestVibeEnvFileAuthStatusAuthorized(t *testing.T) {
envPath := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(envPath, []byte("MISTRAL_API_KEY=test-key\n"), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := vibeEnvFileAuthStatus(envPath, vibeDefaultAPIKeyEnvVar)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestVibeEnvFileAuthStatusUnauthorizedForEmptyValue(t *testing.T) {
envPath := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(envPath, []byte("MISTRAL_API_KEY=\n"), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := vibeEnvFileAuthStatus(envPath, vibeDefaultAPIKeyEnvVar)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}
func TestVibeSessionLogAuthStatusAuthorizedWithAssistantMessage(t *testing.T) {
dir := t.TempDir()
sessionDir := filepath.Join(dir, "session_20260625_071829_d5e8a6eb")
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sessionDir, "messages.jsonl"), []byte(`{"role":"assistant","content":"Hello"}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := vibeSessionLogAuthStatus(context.Background(), dir)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func clearVibeAuthEnv(t *testing.T, names ...string) {
t.Helper()
for _, name := range names {
t.Setenv(name, "")
}
}
func TestGetPromptDeliveryStrategy(t *testing.T) {
s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
if err != nil {
@ -54,12 +141,13 @@ func TestGetLaunchCommandWithPrompt(t *testing.T) {
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
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)
}
@ -125,6 +213,7 @@ func TestGetRestoreCommand(t *testing.T) {
cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{
Session: ports.SessionRef{
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abcd1234-5678-90ab-cdef-1234567890ab"},
WorkspacePath: "/work/repo",
},
Permissions: ports.PermissionModeBypassPermissions,
})
@ -135,7 +224,7 @@ func TestGetRestoreCommand(t *testing.T) {
t.Fatal("ok=false, want true")
}
want := []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve", "--resume", "abcd1234-5678-90ab-cdef-1234567890ab"}
want := []string{"vibe", "--trust", "--output", "text", "--workdir", "/work/repo", "--agent", "auto-approve", "--resume", "abcd1234-5678-90ab-cdef-1234567890ab"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("cmd = %#v, want %#v", cmd, want)
}

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"
@ -132,7 +134,8 @@ func Run() error {
previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx)
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, Telemetry: telemetrySink}),
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}),
Agents: agentsvc.New(),
Sessions: sessionSvc,
Reviews: reviewSvc,
Notifications: notifier,

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:
@ -2587,6 +2680,8 @@ components:
- repo
type: object
tags:
- description: Supported and locally runnable agent adapters
name: agents
- description: Project registry, configuration, and lifecycle administration
name: projects
- description: Agent session lifecycle and messaging

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"
@ -49,6 +50,7 @@ type Service struct {
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,6 +62,9 @@ var _ Manager = (*Service)(nil)
// Deps captures optional collaborators for project use-cases.
type Deps struct {
// 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
@ -73,7 +78,17 @@ func New(store Store) *Service {
// NewWithDeps returns a project service with optional teardown dependencies.
func NewWithDeps(d Deps) *Service {
s := &Service{store: d.Store, sessions: d.Sessions, clock: d.Clock, telemetry: d.Telemetry}
defaultHarness := d.DefaultHarness
if defaultHarness == "" {
defaultHarness = domain.AgentHarness(config.DefaultAgent)
}
s := &Service{
store: d.Store,
sessions: d.Sessions,
clock: d.Clock,
telemetry: d.Telemetry,
defaultHarness: defaultHarness,
}
if s.clock == nil {
s.clock = time.Now
}
@ -111,7 +126,7 @@ func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, erro
if !ok || !row.ArchivedAt.IsZero() {
return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project")
}
p := projectFromRow(row)
p := m.projectFromRow(row)
if row.Kind.WithDefault() == domain.ProjectKindWorkspace {
repos, err := m.store.ListWorkspaceRepos(ctx, row.ID)
if err != nil {
@ -174,12 +189,12 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
})
}
var config domain.ProjectConfig
var projectConfig domain.ProjectConfig
if in.Config != nil {
if err := in.Config.Validate(); err != nil {
return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil)
}
config = *in.Config
projectConfig = *in.Config
}
registeredAt := time.Now()
@ -189,7 +204,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
DisplayName: name,
RegisteredAt: registeredAt,
Kind: domain.ProjectKindSingleRepo,
Config: config,
Config: projectConfig,
}
if in.AsWorkspace {
repos, err := prepareWorkspaceProject(ctx, path, domain.ProjectID(row.ID), registeredAt)
@ -202,7 +217,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register workspace project")
}
m.emitProjectAdded(row, projectCountBefore == 0)
p := projectFromRow(row)
p := m.projectFromRow(row)
p.WorkspaceRepos = workspaceReposFromRecords(repos)
return p, nil
}
@ -224,7 +239,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project")
}
m.emitProjectAdded(row, projectCountBefore == 0)
return projectFromRow(row), nil
return m.projectFromRow(row), nil
}
func (m *Service) activeProjectCount(ctx context.Context) (int, error) {
@ -286,7 +301,7 @@ func (m *Service) SetConfig(ctx context.Context, id domain.ProjectID, in SetConf
if err := m.store.UpsertProject(ctx, row); err != nil {
return Project{}, apierr.Internal("PROJECT_CONFIG_UPDATE_FAILED", "Failed to update project config")
}
return projectFromRow(row), nil
return m.projectFromRow(row), nil
}
// resolveGitOriginURL returns the project's `origin` remote URL via
@ -365,7 +380,7 @@ func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.P
}
}
func projectFromRow(row domain.ProjectRecord) Project {
func (m *Service) projectFromRow(row domain.ProjectRecord) Project {
p := Project{
ID: domain.ProjectID(row.ID),
Name: displayName(row),
@ -373,6 +388,7 @@ func projectFromRow(row domain.ProjectRecord) Project {
Path: row.Path,
Repo: row.RepoOriginURL,
DefaultBranch: row.Config.WithDefaults().DefaultBranch,
Agent: string(m.defaultHarness),
}
if !row.Config.IsZero() {
cfg := row.Config

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)

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

@ -97,7 +97,9 @@ type Store interface {
// presence of any row is the marker; preserved_ref may be empty for clean
// worktrees.
ListSessionWorktrees(ctx context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error)
// DeleteSessionWorktrees clears the "shutdown-saved" restore marker.
// DeleteSessionWorktrees consumes stale shutdown-restore markers. Explicit
// Kill and successful RestoreAll must remove these rows to prevent
// resurrecting sessions the user intentionally terminated.
DeleteSessionWorktrees(ctx context.Context, id domain.SessionID) error
}
@ -450,11 +452,8 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
if err := m.lcm.MarkTerminated(ctx, id); err != nil {
return false, fmt.Errorf("kill %s: %w", id, err)
}
// Clear the restore marker so the next boot's RestoreAll cannot resurrect a
// killed session (#2319). Best-effort: teardown below still matters.
if err := m.store.DeleteSessionWorktrees(ctx, id); err != nil {
m.logger.Warn("kill: delete restore marker failed", "sessionID", id, "error", err)
return false, fmt.Errorf("kill %s: delete restore marker: %w", id, err)
}
// Only tear down what exists. A session may have lost its handle after a
@ -849,12 +848,10 @@ func (m *Manager) RestoreAll(ctx context.Context) error {
} else {
m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err)
}
continue
}
// One-shot: drop the consumed marker so it never outlives one restart
// (#2319). A still-live session re-acquires it at the next quit.
if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil {
m.logger.Warn("restore-all: delete restore marker failed", "sessionID", rec.ID, "error", err)
m.logger.Error("restore-all: delete consumed worktree marker failed", "sessionID", rec.ID, "error", err)
}
}
return nil

View File

@ -564,6 +564,25 @@ func TestKill_DirtyWorkspaceTerminatesAndPreserves(t *testing.T) {
}
}
func TestKill_DeletesStaleRestoreMarker(t *testing.T) {
m, st, _, _ := newManager()
st.sessions["mer-1"] = mkLive("mer-1")
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{
{SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, WorktreePath: "/tmp/wt"},
}
freed, err := m.Kill(ctx, "mer-1")
if err != nil {
t.Fatalf("Kill: %v", err)
}
if !freed {
t.Fatal("Kill freed = false, want true")
}
if rows := st.worktrees["mer-1"]; len(rows) != 0 {
t.Fatalf("stale restore marker = %+v, want deleted", rows)
}
}
// TestKill_OtherWorkspaceErrorStillFails: only the typed dirty refusal is a
// success-with-preserved-workspace; any other teardown failure keeps erroring.
func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) {
@ -574,29 +593,6 @@ func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) {
t.Fatalf("kill err = %v, want workspace error surfaced", err)
}
}
// TestKill_DeletesRestoreMarker covers issue #2319 (a): a user kill is explicit
// terminal intent and must delete the session_worktrees "shutdown-saved" marker.
// A session that carried a marker (e.g. it survived a prior reopen cycle) and is
// then killed must not keep that marker, or the next boot's RestoreAll would
// resurrect it.
func TestKill_DeletesRestoreMarker(t *testing.T) {
m, st, _, _ := newManager()
st.sessions["mer-1"] = mkLive("mer-1")
// The session carries a leftover shutdown-saved marker from a prior cycle.
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
if _, err := m.Kill(ctx, "mer-1"); err != nil {
t.Fatalf("kill err = %v", err)
}
rows, err := st.ListSessionWorktrees(ctx, "mer-1")
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("kill must delete the restore marker, got %d rows", len(rows))
}
}
func TestRestore_ReopensTerminal(t *testing.T) {
m, st, rt, _ := newManager()
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"})
@ -1580,6 +1576,33 @@ func TestRestoreAll_RestoresBothWorkerAndOrchestrator(t *testing.T) {
}
}
func TestRestoreAll_ConsumesMarkersAfterSuccessfulRestore(t *testing.T) {
m, st, rt, _ := newLifecycleManager()
st.sessions["mer-1"] = domain.SessionRecord{
ID: "mer-1",
ProjectID: "mer",
Kind: domain.KindWorker,
Harness: domain.HarnessClaudeCode,
IsTerminated: true,
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
Activity: domain.Activity{State: domain.ActivityExited},
}
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{
{SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, WorktreePath: "/ws/mer-1"},
}
if err := m.RestoreAll(ctx); err != nil {
t.Fatalf("RestoreAll err = %v", err)
}
if rt.created != 1 {
t.Fatalf("RestoreAll must relaunch session, runtime.Create called %d times", rt.created)
}
if rows := st.worktrees["mer-1"]; len(rows) != 0 {
t.Fatalf("consumed restore marker = %+v, want deleted", rows)
}
}
// TestRestoreAll_SkipsSessionsKilledBeforeShutdown verifies (c): a session
// the user killed BEFORE shutdown has no session_worktrees row and must NOT
// be resurrected.
@ -1611,81 +1634,6 @@ func TestRestoreAll_SkipsSessionsKilledBeforeShutdown(t *testing.T) {
}
}
// TestRestoreAll_DeletesMarkerAfterRelaunch covers issue #2319 (b): the
// shutdown-saved marker is one-shot. After RestoreAll relaunches a session, its
// session_worktrees marker is deleted, so a second RestoreAll (with no fresh
// marker) does NOT relaunch it again.
func TestRestoreAll_DeletesMarkerAfterRelaunch(t *testing.T) {
m, st, rt, _ := newLifecycleManager()
st.sessions["mer-1"] = domain.SessionRecord{
ID: "mer-1",
ProjectID: "mer",
Kind: domain.KindWorker,
Harness: domain.HarnessClaudeCode,
IsTerminated: true,
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
Activity: domain.Activity{State: domain.ActivityExited},
}
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
if err := m.RestoreAll(ctx); err != nil {
t.Fatalf("RestoreAll err = %v", err)
}
if rt.created != 1 {
t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created)
}
rows, err := st.ListSessionWorktrees(ctx, "mer-1")
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("RestoreAll must delete the one-shot marker, got %d rows", len(rows))
}
}
// TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot covers issue #2319 (c),
// the killed-session-resurrection scenario. A terminated session WITH a marker
// is relaunched exactly once; on a second RestoreAll (no new marker) it stays
// terminated and is not relaunched again.
func TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot(t *testing.T) {
m, st, rt, _ := newLifecycleManager()
st.sessions["mer-1"] = domain.SessionRecord{
ID: "mer-1",
ProjectID: "mer",
Kind: domain.KindWorker,
Harness: domain.HarnessClaudeCode,
IsTerminated: true,
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
Activity: domain.Activity{State: domain.ActivityExited},
}
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
// First boot: marker present, session relaunches once.
if err := m.RestoreAll(ctx); err != nil {
t.Fatalf("first RestoreAll err = %v", err)
}
if rt.created != 1 {
t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created)
}
// Simulate the user killing the relaunched session before the next quit, so
// it has no fresh marker, then a second boot.
if _, err := m.Kill(ctx, "mer-1"); err != nil {
t.Fatalf("kill err = %v", err)
}
if err := m.RestoreAll(ctx); err != nil {
t.Fatalf("second RestoreAll err = %v", err)
}
if rt.created != 1 {
t.Fatalf("killed session must NOT be resurrected on second boot, runtime.Create total = %d, want 1", rt.created)
}
if !st.sessions["mer-1"].IsTerminated {
t.Error("killed session must remain terminated after second RestoreAll")
}
}
// TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a
// non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace
// restore but before relaunching.

View File

@ -15,7 +15,6 @@ Start with [architecture.md](architecture.md) for the current backend model and
| [architecture.md](architecture.md) | Current backend model, package layout, status derivation, persistence/CDC, and load-bearing rules. |
| [backend-code-structure.md](backend-code-structure.md) | Package ownership rules for the Go backend: domain, services, ports, adapters, storage, HTTP, CLI, and daemon wiring. |
| [cli/README.md](cli/README.md) | CLI commands and daemon control surface. |
| [agent/README.md](agent/README.md) | Agent adapter contract, hook methodology, and session-info derivation. |
| [STATUS.md](STATUS.md) | What is shipped on `main` today and what is still in flight. |
| [stack.md](stack.md) | Accepted library/runtime choices, pending stack decisions, and dependencies explicitly avoided for V1. |

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