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:
parent
d18ea87f57
commit
a639e2025c
|
|
@ -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 |
|
| [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 |
|
| [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules |
|
||||||
| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract |
|
| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract |
|
||||||
| [Agent Adapter Contract](docs/agent/README.md) | Agent adapter interface and hook behavior |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -200,3 +200,15 @@ func TestHooksLifecycle(t *testing.T) {
|
||||||
t.Fatal("expected hooks to be uninstalled after UninstallHooks")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
|
@ -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, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package claudecode
|
package claudecode
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -26,6 +27,7 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
|
@ -60,6 +62,7 @@ func New() *Plugin {
|
||||||
|
|
||||||
var _ adapters.Adapter = (*Plugin)(nil)
|
var _ adapters.Adapter = (*Plugin)(nil)
|
||||||
var _ ports.Agent = (*Plugin)(nil)
|
var _ ports.Agent = (*Plugin)(nil)
|
||||||
|
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||||
|
|
||||||
// Manifest returns the adapter's static self-description.
|
// Manifest returns the adapter's static self-description.
|
||||||
func (p *Plugin) Manifest() adapters.Manifest {
|
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
|
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
|
// claudeSessionUUID maps an AO session id onto a stable Claude Code
|
||||||
// session UUID via UUIDv5 over a fixed namespace, so the same AO session
|
// session UUID via UUIDv5 over a fixed namespace, so the same AO session
|
||||||
// always resolves to the same Claude session.
|
// always resolves to the same Claude session.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestEnsureWorkspaceTrustedCreatesEntry(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
cfgPath := filepath.Join(dir, ".claude.json")
|
cfgPath := filepath.Join(dir, ".claude.json")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
// workspace-local Cline hooks, and reading hook-derived session info.
|
// workspace-local Cline hooks, and reading hook-derived session info.
|
||||||
//
|
//
|
||||||
// Cline is an autonomous coding agent that runs in the terminal (binary
|
// 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
|
// "cline", installed via `npm i -g cline`). AO drives task launches headlessly
|
||||||
// the prompt as a positional argument and requesting NDJSON output with
|
// by passing the prompt as a positional argument and requesting NDJSON output
|
||||||
// `--json`, which Cline emits one event per line for machine parsing.
|
// 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
|
// AO-managed sessions derive native session identity from Cline hooks
|
||||||
// (the workspace-local `.clinerules/hooks/` executable scripts AO installs)
|
// (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
|
return ports.ConfigSpec{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLaunchCommand builds the argv to start a new headless Cline session,
|
// GetLaunchCommand builds the argv to start a new Cline session. Prompted
|
||||||
// requesting machine-readable NDJSON output (`--json`), applying the approval
|
// launches request machine-readable NDJSON output (`--json`). Promptless
|
||||||
// flags, an optional system-prompt override (`-s`), and the initial prompt as
|
// launches stay interactive because Cline's JSON output mode requires a prompt
|
||||||
// the trailing positional argument. The prompt is placed after `--` so a
|
// argument or piped stdin. The prompt is placed after `--` so a leading "-" is
|
||||||
// leading "-" is not read as a flag.
|
// not read as a flag.
|
||||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||||
binary, err := p.clineBinary(ctx)
|
binary, err := p.clineBinary(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd = []string{binary, "--json"}
|
cmd = []string{binary}
|
||||||
|
if cfg.Prompt != "" {
|
||||||
|
cmd = append(cmd, "--json")
|
||||||
|
}
|
||||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||||
|
|
||||||
if cfg.SystemPrompt != "" {
|
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:
|
// GetRestoreCommand rebuilds the argv that continues an existing Cline session:
|
||||||
// `cline --json [approval flags] --id <agentSessionId>`. ok is false when the
|
// `cline [approval flags] --id <agentSessionId>`. Resumes are interactive
|
||||||
// hook-derived native session id has not landed yet, so callers can fall back
|
// because no prompt is supplied here. ok is false when the hook-derived native
|
||||||
// to fresh launch behavior.
|
// 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) {
|
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
|
|
@ -120,7 +126,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd = make([]string, 0, 8)
|
cmd = make([]string, 0, 8)
|
||||||
cmd = append(cmd, binary, "--json")
|
cmd = append(cmd, binary)
|
||||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||||
cmd = append(cmd, "--id", agentSessionID)
|
cmd = append(cmd, "--id", agentSessionID)
|
||||||
return cmd, true, nil
|
return cmd, true, nil
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -254,7 +278,6 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
||||||
}
|
}
|
||||||
want := []string{
|
want := []string{
|
||||||
"cline",
|
"cline",
|
||||||
"--json",
|
|
||||||
"--auto-approve", "true",
|
"--auto-approve", "true",
|
||||||
"--id", "session-123",
|
"--id", "session-123",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -13,8 +13,10 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
|
|
@ -34,6 +36,7 @@ func New() *Plugin {
|
||||||
|
|
||||||
var _ adapters.Adapter = (*Plugin)(nil)
|
var _ adapters.Adapter = (*Plugin)(nil)
|
||||||
var _ ports.Agent = (*Plugin)(nil)
|
var _ ports.Agent = (*Plugin)(nil)
|
||||||
|
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||||
|
|
||||||
// Manifest returns the adapter's static self-description.
|
// Manifest returns the adapter's static self-description.
|
||||||
func (p *Plugin) Manifest() adapters.Manifest {
|
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
|
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,
|
// ResolveCodexBinary returns the path to the codex binary on this machine,
|
||||||
// searching PATH then a handful of well-known install locations
|
// searching PATH then a handful of well-known install locations
|
||||||
// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback
|
// (Homebrew, Cargo, npm global, NVM). Returns "codex" as a last-ditch
|
||||||
// so callers see a clear "command not found" rather than an empty argv.
|
// fallback so callers see a clear "command not found" rather than an empty
|
||||||
|
// argv.
|
||||||
func ResolveCodexBinary(ctx context.Context) (string, error) {
|
func ResolveCodexBinary(ctx context.Context) (string, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -203,6 +233,7 @@ func ResolveCodexBinary(ctx context.Context) (string, error) {
|
||||||
filepath.Join(home, ".cargo", "bin", "codex"),
|
filepath.Join(home, ".cargo", "bin", "codex"),
|
||||||
filepath.Join(home, ".npm", "bin", "codex"),
|
filepath.Join(home, ".npm", "bin", "codex"),
|
||||||
)
|
)
|
||||||
|
candidates = append(candidates, nvmNodeBinCandidates(home, "codex")...)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
|
|
@ -217,6 +248,14 @@ func ResolveCodexBinary(ctx context.Context) (string, error) {
|
||||||
return "", fmt.Errorf("codex: %w", ports.ErrAgentBinaryNotFound)
|
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 {
|
func resolveNativeWindowsCodex(path string) string {
|
||||||
if runtime.GOOS != "windows" || !strings.EqualFold(filepath.Ext(path), ".cmd") {
|
if runtime.GOOS != "windows" || !strings.EqualFold(filepath.Ext(path), ".cmd") {
|
||||||
return path
|
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)
|
info, err := os.Stat(path)
|
||||||
return err == nil && !info.IsDir()
|
return err == nil && !info.IsDir()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
func TestGetConfigSpecEmpty(t *testing.T) {
|
||||||
spec, err := (&Plugin{}).GetConfigSpec(context.Background())
|
spec, err := (&Plugin{}).GetConfigSpec(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
// "copilot", installed via npm "@github/copilot"), NOT the older `gh copilot`
|
// "copilot", installed via npm "@github/copilot"), NOT the older `gh copilot`
|
||||||
// suggest/explain extension.
|
// suggest/explain extension.
|
||||||
//
|
//
|
||||||
// Launch runs the CLI in non-interactive ("programmatic") mode with `-p
|
// Launch runs the CLI in interactive mode so AO can keep a durable terminal
|
||||||
// <prompt>` so it executes the task and exits. Permission modes map onto the
|
// pane attached to the session. Permission modes map onto the CLI's allow flags
|
||||||
// CLI's allow flags (`--allow-tool`, `--allow-all-tools`, `--allow-all`).
|
// (`--allow-tool`, `--allow-all-tools`, `--allow-all`).
|
||||||
// Restore continues an existing session via `--resume <agentSessionId>`; the
|
// Restore continues an existing session via `--resume <agentSessionId>`; the
|
||||||
// native session id (a UUID under ~/.copilot/session-state/) is captured by the
|
// native session id (a UUID under ~/.copilot/session-state/) is captured by the
|
||||||
// SessionStart hook AO installs (see hooks.go).
|
// 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
|
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
|
// The prompt is delivered after the process starts; using `-p` runs Copilot in
|
||||||
// mode and exits when done. Copilot CLI does not have a documented
|
// programmatic mode and exits when done, which leaves AO's terminal pane blank
|
||||||
// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored.
|
// 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) {
|
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||||
binary, err := p.copilotBinary(ctx)
|
binary, err := p.copilotBinary(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -90,20 +91,16 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
||||||
cmd = []string{binary}
|
cmd = []string{binary}
|
||||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||||
|
|
||||||
if cfg.Prompt != "" {
|
|
||||||
cmd = append(cmd, "-p", cfg.Prompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cmd, nil
|
return cmd, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPromptDeliveryStrategy reports that Copilot receives its prompt in the
|
// GetPromptDeliveryStrategy reports that Copilot receives its prompt after the
|
||||||
// launch command itself (via `-p`).
|
// interactive process starts.
|
||||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return ports.PromptDeliveryInCommand, nil
|
return ports.PromptDeliveryAfterStart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRestoreCommand rebuilds the argv that continues an existing Copilot
|
// 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 path, err := exec.LookPath("copilot"); err == nil && path != "" {
|
||||||
|
if native := copilotNativeBinaryForLoader(path); native != "" {
|
||||||
|
return native, nil
|
||||||
|
}
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,11 +206,15 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) {
|
||||||
filepath.Join(home, ".copilot", "bin", "copilot"),
|
filepath.Join(home, ".copilot", "bin", "copilot"),
|
||||||
filepath.Join(home, ".npm", "bin", "copilot"),
|
filepath.Join(home, ".npm", "bin", "copilot"),
|
||||||
filepath.Join(home, ".local", "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 {
|
for _, candidate := range candidates {
|
||||||
if fileExists(candidate) {
|
if fileExists(candidate) {
|
||||||
|
if native := copilotNativeBinaryForLoader(candidate); native != "" {
|
||||||
|
return native, nil
|
||||||
|
}
|
||||||
return candidate, nil
|
return candidate, nil
|
||||||
}
|
}
|
||||||
if err := ctx.Err(); err != 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)
|
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) {
|
func (p *Plugin) copilotBinary(ctx context.Context) (string, error) {
|
||||||
p.binaryMu.Lock()
|
p.binaryMu.Lock()
|
||||||
defer p.binaryMu.Unlock()
|
defer p.binaryMu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||||
|
|
@ -33,7 +34,7 @@ func TestGetLaunchCommandBuildsArgv(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := []string{"copilot", "--allow-all", "-p", "-fix this"}
|
want := []string{"copilot", "--allow-all"}
|
||||||
if !reflect.DeepEqual(cmd, want) {
|
if !reflect.DeepEqual(cmd, want) {
|
||||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +124,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got != ports.PromptDeliveryInCommand {
|
if got != ports.PromptDeliveryAfterStart {
|
||||||
t.Fatalf("unexpected strategy: %q", got)
|
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) {
|
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
||||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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:
|
// 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
|
// 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
|
// 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,
|
// 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) {
|
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||||
binary, err := p.gooseBinary(ctx)
|
binary, err := p.gooseBinary(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -106,8 +109,9 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
||||||
cmd = append(cmd, "--system", systemPrompt)
|
cmd = append(cmd, "--system", systemPrompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Prompt != "" {
|
cmd = append(cmd, "-t", cfg.Prompt)
|
||||||
cmd = append(cmd, "-t", cfg.Prompt)
|
if cfg.Prompt == "" {
|
||||||
|
cmd = append(cmd, "--interactive")
|
||||||
}
|
}
|
||||||
|
|
||||||
return cmd, nil
|
return cmd, nil
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
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) {
|
func TestContextCancellationIsHonored(t *testing.T) {
|
||||||
plugin := &Plugin{resolvedBinary: "goose"}
|
plugin := &Plugin{resolvedBinary: "goose"}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
"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/domain"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"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) {
|
func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
|
||||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
|
|
@ -441,6 +480,13 @@ func containsSubsequence(values []string, needle []string) bool {
|
||||||
return false
|
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 {
|
func countKiroHookCommand(entries []kiroHookEntry, command string) int {
|
||||||
count := 0
|
count := 0
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -17,15 +17,21 @@ package opencode
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // register sqlite driver for opencode session metadata probes
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -55,6 +61,7 @@ func New() *Plugin {
|
||||||
|
|
||||||
var _ adapters.Adapter = (*Plugin)(nil)
|
var _ adapters.Adapter = (*Plugin)(nil)
|
||||||
var _ ports.Agent = (*Plugin)(nil)
|
var _ ports.Agent = (*Plugin)(nil)
|
||||||
|
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||||
|
|
||||||
// Manifest returns the adapter's static self-description.
|
// Manifest returns the adapter's static self-description.
|
||||||
func (p *Plugin) Manifest() adapters.Manifest {
|
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
|
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
|
// appendPermissionFlags maps AO's permission modes onto opencode's single
|
||||||
// approval flag. opencode exposes only --dangerously-skip-permissions (no
|
// approval flag. opencode exposes only --dangerously-skip-permissions (no
|
||||||
// graduated accept-edits/auto modes), so:
|
// 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,
|
// ResolveOpenCodeBinary returns the path to the opencode binary on this machine,
|
||||||
// searching PATH then a handful of well-known install locations (the install
|
// searching PATH then a handful of well-known install locations (the install
|
||||||
// script's ~/.opencode/bin, Homebrew, npm global). Returns "opencode" as a
|
// script's ~/.opencode/bin, Homebrew, npm global).
|
||||||
// last-ditch fallback so callers see a clear "command not found" rather than an
|
|
||||||
// empty argv.
|
|
||||||
func ResolveOpenCodeBinary(ctx context.Context) (string, error) {
|
func ResolveOpenCodeBinary(ctx context.Context) (string, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -211,7 +397,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) {
|
||||||
return candidate, nil
|
return candidate, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "opencode", nil
|
return "", fmt.Errorf("opencode: %w", ports.ErrAgentBinaryNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
if path, err := exec.LookPath("opencode"); err == nil && path != "" {
|
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) {
|
func (p *Plugin) opencodeBinary(ctx context.Context) (string, error) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package opencode
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
@ -11,6 +13,279 @@ import (
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"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) {
|
func TestGetLaunchCommandBuildsArgv(t *testing.T) {
|
||||||
plugin := &Plugin{resolvedBinary: "opencode"}
|
plugin := &Plugin{resolvedBinary: "opencode"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -83,8 +83,9 @@ func Build() (*adapters.Registry, error) {
|
||||||
// harness is the adapter's manifest id, which is also the domain.AgentHarness
|
// harness is the adapter's manifest id, which is also the domain.AgentHarness
|
||||||
// value a session carries and the `--harness` flag users pass.
|
// value a session carries and the `--harness` flag users pass.
|
||||||
type HarnessAgent struct {
|
type HarnessAgent struct {
|
||||||
Harness domain.AgentHarness
|
Harness domain.AgentHarness
|
||||||
Agent ports.Agent
|
Manifest adapters.Manifest
|
||||||
|
Agent ports.Agent
|
||||||
}
|
}
|
||||||
|
|
||||||
// Harnessed returns every shipped adapter that drives an agent, paired with its
|
// Harnessed returns every shipped adapter that drives an agent, paired with its
|
||||||
|
|
@ -99,8 +100,9 @@ func Harnessed() []HarnessAgent {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, HarnessAgent{
|
out = append(out, HarnessAgent{
|
||||||
Harness: domain.AgentHarness(a.Manifest().ID),
|
Harness: domain.AgentHarness(a.Manifest().ID),
|
||||||
Agent: agent,
|
Manifest: a.Manifest(),
|
||||||
|
Agent: agent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ func TestGetAgentHooksFootprintIsGitignored(t *testing.T) {
|
||||||
for _, ha := range Harnessed() {
|
for _, ha := range Harnessed() {
|
||||||
t.Run(string(ha.Harness), func(t *testing.T) {
|
t.Run(string(ha.Harness), func(t *testing.T) {
|
||||||
ws := t.TempDir()
|
ws := t.TempDir()
|
||||||
|
if ha.Harness == "autohand" {
|
||||||
|
t.Setenv("AUTOHAND_CONFIG", filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
}
|
||||||
cfg := ports.WorkspaceHookConfig{
|
cfg := ports.WorkspaceHookConfig{
|
||||||
SessionID: "proj-1",
|
SessionID: "proj-1",
|
||||||
WorkspacePath: ws,
|
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.
|
// workspaceFiles returns every regular file under root, relative to root.
|
||||||
func workspaceFiles(t *testing.T, root string) []string {
|
func workspaceFiles(t *testing.T, root string) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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:
|
// 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
|
// The prompt is delivered through `-p` (programmatic mode), so AO uses
|
||||||
// in-command delivery. `--trust` skips the trust prompt for automation and
|
// in-command delivery. `--trust` skips the trust prompt for automation and
|
||||||
// `--output text` pins the output format. Vibe exposes no CLI system-prompt
|
// `--output text` pins the output format. `--workdir` is passed explicitly
|
||||||
// flag (system prompts are config-driven), so SystemPrompt is not forwarded.
|
// 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) {
|
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -94,6 +96,7 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd = []string{binary, "--trust", "--output", "text"}
|
cmd = []string{binary, "--trust", "--output", "text"}
|
||||||
|
appendWorkdirFlag(&cmd, cfg.WorkspacePath)
|
||||||
appendAgentFlags(&cmd, cfg.Permissions)
|
appendAgentFlags(&cmd, cfg.Permissions)
|
||||||
if cfg.Prompt != "" {
|
if cfg.Prompt != "" {
|
||||||
cmd = append(cmd, "-p", 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 = make([]string, 0, 8)
|
||||||
cmd = append(cmd, binary, "--trust", "--output", "text")
|
cmd = append(cmd, binary, "--trust", "--output", "text")
|
||||||
|
appendWorkdirFlag(&cmd, cfg.Session.WorkspacePath)
|
||||||
appendAgentFlags(&cmd, cfg.Permissions)
|
appendAgentFlags(&cmd, cfg.Permissions)
|
||||||
cmd = append(cmd, "--resume", agentSessionID)
|
cmd = append(cmd, "--resume", agentSessionID)
|
||||||
return cmd, true, nil
|
return cmd, true, nil
|
||||||
|
|
@ -148,6 +152,12 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
||||||
return ports.SessionInfo{}, false, nil
|
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`
|
// appendAgentFlags maps AO permission modes onto Vibe's builtin `--agent`
|
||||||
// profiles. PermissionModeDefault (and the empty mode) emit no flag so Vibe
|
// profiles. PermissionModeDefault (and the empty mode) emit no flag so Vibe
|
||||||
// resolves its starting agent from the user's `default_agent` config.
|
// resolves its starting agent from the user's `default_agent` config.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package vibe
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"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) {
|
func TestGetPromptDeliveryStrategy(t *testing.T) {
|
||||||
s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -52,14 +139,15 @@ func TestGetPromptDeliveryStrategy(t *testing.T) {
|
||||||
func TestGetLaunchCommandWithPrompt(t *testing.T) {
|
func TestGetLaunchCommandWithPrompt(t *testing.T) {
|
||||||
p := &Plugin{resolvedBinary: "vibe"}
|
p := &Plugin{resolvedBinary: "vibe"}
|
||||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||||
Permissions: ports.PermissionModeBypassPermissions,
|
Permissions: ports.PermissionModeBypassPermissions,
|
||||||
Prompt: "add a health check",
|
Prompt: "add a health check",
|
||||||
|
WorkspacePath: "/work/repo",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
if !reflect.DeepEqual(cmd, want) {
|
||||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +212,8 @@ func TestGetRestoreCommand(t *testing.T) {
|
||||||
p := &Plugin{resolvedBinary: "vibe"}
|
p := &Plugin{resolvedBinary: "vibe"}
|
||||||
cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||||
Session: ports.SessionRef{
|
Session: ports.SessionRef{
|
||||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abcd1234-5678-90ab-cdef-1234567890ab"},
|
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abcd1234-5678-90ab-cdef-1234567890ab"},
|
||||||
|
WorkspacePath: "/work/repo",
|
||||||
},
|
},
|
||||||
Permissions: ports.PermissionModeBypassPermissions,
|
Permissions: ports.PermissionModeBypassPermissions,
|
||||||
})
|
})
|
||||||
|
|
@ -135,7 +224,7 @@ func TestGetRestoreCommand(t *testing.T) {
|
||||||
t.Fatal("ok=false, want true")
|
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) {
|
if !reflect.DeepEqual(cmd, want) {
|
||||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ func TestWaitForStoppedKeepsRunFileFromConcurrentStart(t *testing.T) {
|
||||||
}
|
}
|
||||||
if info == nil {
|
if info == nil {
|
||||||
t.Fatal("new daemon's run-file was deleted by stop of a different PID")
|
t.Fatal("new daemon's run-file was deleted by stop of a different PID")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if info.PID != newPID {
|
if info.PID != newPID {
|
||||||
t.Fatalf("run-file PID = %d, want %d (new daemon)", info.PID, newPID)
|
t.Fatalf("run-file PID = %d, want %d (new daemon)", info.PID, newPID)
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,13 @@ import (
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect"
|
"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/config"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/daemon/supervisor"
|
"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/httpd"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/notify"
|
"github.com/aoagents/agent-orchestrator/backend/internal/notify"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/preview"
|
"github.com/aoagents/agent-orchestrator/backend/internal/preview"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
"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"
|
importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer"
|
||||||
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
|
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
|
||||||
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
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)
|
previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx)
|
||||||
|
|
||||||
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
|
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,
|
Sessions: sessionSvc,
|
||||||
Reviews: reviewSvc,
|
Reviews: reviewSvc,
|
||||||
Notifications: notifier,
|
Notifications: notifier,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
|
|
||||||
// APIDeps bundles every service the API layer's controllers depend on.
|
// APIDeps bundles every service the API layer's controllers depend on.
|
||||||
type APIDeps struct {
|
type APIDeps struct {
|
||||||
|
Agents controllers.AgentCatalog
|
||||||
Projects projectsvc.Manager
|
Projects projectsvc.Manager
|
||||||
Sessions controllers.SessionService
|
Sessions controllers.SessionService
|
||||||
Activity controllers.ActivityRecorder
|
Activity controllers.ActivityRecorder
|
||||||
|
|
@ -36,6 +37,7 @@ type APIDeps struct {
|
||||||
// router invokes to mount the /api/v1 surface.
|
// router invokes to mount the /api/v1 surface.
|
||||||
type API struct {
|
type API struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
|
agents *controllers.AgentsController
|
||||||
projects *controllers.ProjectsController
|
projects *controllers.ProjectsController
|
||||||
sessions *controllers.SessionsController
|
sessions *controllers.SessionsController
|
||||||
prs *controllers.PRsController
|
prs *controllers.PRsController
|
||||||
|
|
@ -51,6 +53,9 @@ type API struct {
|
||||||
func NewAPI(cfg config.Config, deps APIDeps) *API {
|
func NewAPI(cfg config.Config, deps APIDeps) *API {
|
||||||
return &API{
|
return &API{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
agents: &controllers.AgentsController{
|
||||||
|
Catalog: deps.Agents,
|
||||||
|
},
|
||||||
projects: &controllers.ProjectsController{
|
projects: &controllers.ProjectsController{
|
||||||
Mgr: deps.Projects,
|
Mgr: deps.Projects,
|
||||||
},
|
},
|
||||||
|
|
@ -80,6 +85,7 @@ func (a *API) Register(root chi.Router) {
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.Timeout(timeout))
|
r.Use(middleware.Timeout(timeout))
|
||||||
|
a.agents.Register(r)
|
||||||
a.projects.Register(r)
|
a.projects.Register(r)
|
||||||
a.sessions.Register(r)
|
a.sessions.Register(r)
|
||||||
a.prs.Register(r)
|
a.prs.Register(r)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,56 @@ servers:
|
||||||
- description: Local daemon (loopback only)
|
- description: Local daemon (loopback only)
|
||||||
url: http://127.0.0.1:3001
|
url: http://127.0.0.1:3001
|
||||||
paths:
|
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:
|
/api/v1/events:
|
||||||
get:
|
get:
|
||||||
operationId: streamEvents
|
operationId: streamEvents
|
||||||
|
|
@ -1486,6 +1536,24 @@ components:
|
||||||
permissions:
|
permissions:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
ClaimPRRequest:
|
||||||
properties:
|
properties:
|
||||||
allowTakeover:
|
allowTakeover:
|
||||||
|
|
@ -1695,6 +1763,31 @@ components:
|
||||||
- ok
|
- ok
|
||||||
- sessionId
|
- sessionId
|
||||||
type: object
|
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:
|
ListNotificationsResponse:
|
||||||
properties:
|
properties:
|
||||||
notifications:
|
notifications:
|
||||||
|
|
@ -2587,6 +2680,8 @@ components:
|
||||||
- repo
|
- repo
|
||||||
type: object
|
type: object
|
||||||
tags:
|
tags:
|
||||||
|
- description: Supported and locally runnable agent adapters
|
||||||
|
name: agents
|
||||||
- description: Project registry, configuration, and lifecycle administration
|
- description: Project registry, configuration, and lifecycle administration
|
||||||
name: projects
|
name: projects
|
||||||
- description: Agent session lifecycle and messaging
|
- description: Agent session lifecycle and messaging
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ func Build() ([]byte, error) {
|
||||||
*(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"),
|
*(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"),
|
||||||
}
|
}
|
||||||
r.Spec.Tags = []openapi31.Tag{
|
r.Spec.Tags = []openapi31.Tag{
|
||||||
|
*(&openapi31.Tag{Name: "agents"}).WithDescription(
|
||||||
|
"Supported and locally runnable agent adapters"),
|
||||||
*(&openapi31.Tag{Name: "projects"}).WithDescription(
|
*(&openapi31.Tag{Name: "projects"}).WithDescription(
|
||||||
"Project registry, configuration, and lifecycle administration"),
|
"Project registry, configuration, and lifecycle administration"),
|
||||||
*(&openapi31.Tag{Name: "sessions"}).WithDescription(
|
*(&openapi31.Tag{Name: "sessions"}).WithDescription(
|
||||||
|
|
@ -170,6 +172,8 @@ var schemaNames = map[string]string{
|
||||||
"ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest",
|
"ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest",
|
||||||
"ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse",
|
"ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse",
|
||||||
"ControllersOrchestratorResponse": "OrchestratorResponse",
|
"ControllersOrchestratorResponse": "OrchestratorResponse",
|
||||||
|
"AgentInventory": "ListAgentsResponse",
|
||||||
|
"AgentInfo": "AgentInfo",
|
||||||
"ControllersListNotificationsQuery": "ListNotificationsQuery",
|
"ControllersListNotificationsQuery": "ListNotificationsQuery",
|
||||||
"ControllersNotificationStreamQuery": "NotificationStreamQuery",
|
"ControllersNotificationStreamQuery": "NotificationStreamQuery",
|
||||||
"ControllersNotificationIDParam": "NotificationIDParam",
|
"ControllersNotificationIDParam": "NotificationIDParam",
|
||||||
|
|
@ -279,6 +283,7 @@ type operation struct {
|
||||||
|
|
||||||
func operations() []operation {
|
func operations() []operation {
|
||||||
ops := append([]operation{}, eventOperations()...)
|
ops := append([]operation{}, eventOperations()...)
|
||||||
|
ops = append(ops, agentOperations()...)
|
||||||
ops = append(ops, projectOperations()...)
|
ops = append(ops, projectOperations()...)
|
||||||
ops = append(ops, sessionOperations()...)
|
ops = append(ops, sessionOperations()...)
|
||||||
ops = append(ops, prOperations()...)
|
ops = append(ops, prOperations()...)
|
||||||
|
|
@ -288,6 +293,29 @@ func operations() []operation {
|
||||||
return ops
|
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
|
// importOperations declares the 2 /import operations. Must stay 1:1 with
|
||||||
// the routes ImportController.Register mounts (enforced by the parity test).
|
// the routes ImportController.Register mounts (enforced by the parity test).
|
||||||
func importOperations() []operation {
|
func importOperations() []operation {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
|
"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"
|
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||||
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
||||||
)
|
)
|
||||||
|
|
@ -435,6 +436,15 @@ type OrchestratorResponse struct {
|
||||||
ProjectName string `json:"projectName,omitempty"`
|
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.
|
// ListNotificationsQuery is the query string accepted by GET /api/v1/notifications.
|
||||||
type ListNotificationsQuery struct {
|
type ListNotificationsQuery struct {
|
||||||
Status string `query:"status,omitempty" enum:"unread" description:"Notification status filter. V1 supports only unread."`
|
Status string `query:"status,omitempty" enum:"unread" description:"Notification status filter. V1 supports only unread."`
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ func TestServerLifecycle(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
runErr := make(chan error, 1)
|
runErr := make(chan error, 1)
|
||||||
go func() { runErr <- srv.Run(ctx) }()
|
go func() { runErr <- srv.Run(ctx) }()
|
||||||
|
|
||||||
|
|
@ -109,6 +110,7 @@ func TestServerLifecycle(t *testing.T) {
|
||||||
}
|
}
|
||||||
if info == nil {
|
if info == nil {
|
||||||
t.Fatal("run-file not written while server running")
|
t.Fatal("run-file not written while server running")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if info.Port == 0 {
|
if info.Port == 0 {
|
||||||
t.Error("run-file recorded port 0; want the actual bound port")
|
t.Error("run-file recorded port 0; want the actual bound port")
|
||||||
|
|
|
||||||
|
|
@ -681,6 +681,7 @@ func TestPoll_DiscoveredPRPersistedAsBaselineBeforeRefresh(t *testing.T) {
|
||||||
}
|
}
|
||||||
if baseline == nil {
|
if baseline == nil {
|
||||||
t.Fatalf("discovered PR #1 not persisted as a baseline row; writes=%#v", store.writes)
|
t.Fatalf("discovered PR #1 not persisted as a baseline row; writes=%#v", store.writes)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if baseline.Merged || baseline.Closed {
|
if baseline.Merged || baseline.Closed {
|
||||||
t.Fatalf("baseline row must be open, got merged=%v closed=%v", baseline.Merged, baseline.Closed)
|
t.Fatalf("baseline row must be open, got merged=%v closed=%v", baseline.Merged, baseline.Closed)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,22 @@ import (
|
||||||
// for a live session.
|
// for a live session.
|
||||||
var ErrAgentBinaryNotFound = errors.New("agent: binary not found on PATH")
|
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, …)
|
// Agent is the contract every CLI coding agent adapter (claude-code, codex, …)
|
||||||
// must satisfy. It supplies the argv and process configuration the Session
|
// must satisfy. It supplies the argv and process configuration the Session
|
||||||
// Manager needs to launch, restore, and read back a native agent 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)
|
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,
|
// 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
|
// 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
|
// without depending on the concrete adapter registry. ok=false means no adapter
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ func TestWriteReadRoundTrip(t *testing.T) {
|
||||||
}
|
}
|
||||||
if got == nil {
|
if got == nil {
|
||||||
t.Fatal("Read returned nil for an existing file")
|
t.Fatal("Read returned nil for an existing file")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if got.PID != want.PID || got.Port != want.Port || !got.StartedAt.Equal(want.StartedAt) {
|
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)
|
t.Errorf("round trip mismatch: got %+v, want %+v", *got, want)
|
||||||
|
|
@ -44,6 +45,7 @@ func TestWriteReadRoundTripOwner(t *testing.T) {
|
||||||
}
|
}
|
||||||
if got == nil {
|
if got == nil {
|
||||||
t.Fatal("Read returned nil for an existing file")
|
t.Fatal("Read returned nil for an existing file")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if got.Owner != "app" {
|
if got.Owner != "app" {
|
||||||
t.Errorf("Owner round trip: got %q, want %q", 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 {
|
if got == nil {
|
||||||
t.Fatal("Read returned nil for headless file")
|
t.Fatal("Read returned nil for headless file")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if got.Owner != "" {
|
if got.Owner != "" {
|
||||||
t.Errorf("headless Owner round trip: got %q, want %q", 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 {
|
if live == nil {
|
||||||
t.Fatal("CheckStale on live PID = nil, want the live Info")
|
t.Fatal("CheckStale on live PID = nil, want the live Info")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if live.PID != os.Getpid() {
|
if live.PID != os.Getpid() {
|
||||||
t.Errorf("live.PID = %d, want %d", live.PID, os.Getpid())
|
t.Errorf("live.PID = %d, want %d", live.PID, os.Getpid())
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
|
|
@ -45,10 +46,11 @@ type SessionTeardowner interface {
|
||||||
|
|
||||||
// Service implements project registration and lookup use-cases for controllers.
|
// Service implements project registration and lookup use-cases for controllers.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store Store
|
store Store
|
||||||
sessions SessionTeardowner
|
sessions SessionTeardowner
|
||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
telemetry ports.EventSink
|
telemetry ports.EventSink
|
||||||
|
defaultHarness domain.AgentHarness
|
||||||
// addMu serialises the whole body of Add. Workspace registration performs
|
// addMu serialises the whole body of Add. Workspace registration performs
|
||||||
// filesystem mutations (git init, .gitignore writes, commits) that are not
|
// filesystem mutations (git init, .gitignore writes, commits) that are not
|
||||||
// covered by the store's own writeMu, so path/id conflict checks plus the
|
// covered by the store's own writeMu, so path/id conflict checks plus the
|
||||||
|
|
@ -60,10 +62,13 @@ var _ Manager = (*Service)(nil)
|
||||||
|
|
||||||
// Deps captures optional collaborators for project use-cases.
|
// Deps captures optional collaborators for project use-cases.
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Store Store
|
// DefaultHarness is the daemon's configured default agent (AO_AGENT).
|
||||||
Sessions SessionTeardowner
|
// When empty, the service falls back to config.DefaultAgent.
|
||||||
Clock func() time.Time
|
DefaultHarness domain.AgentHarness
|
||||||
Telemetry ports.EventSink
|
Store Store
|
||||||
|
Sessions SessionTeardowner
|
||||||
|
Clock func() time.Time
|
||||||
|
Telemetry ports.EventSink
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a project service backed by the given durable store.
|
// New returns a project service backed by the given durable store.
|
||||||
|
|
@ -73,7 +78,17 @@ func New(store Store) *Service {
|
||||||
|
|
||||||
// NewWithDeps returns a project service with optional teardown dependencies.
|
// NewWithDeps returns a project service with optional teardown dependencies.
|
||||||
func NewWithDeps(d Deps) *Service {
|
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 {
|
if s.clock == nil {
|
||||||
s.clock = time.Now
|
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() {
|
if !ok || !row.ArchivedAt.IsZero() {
|
||||||
return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project")
|
return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project")
|
||||||
}
|
}
|
||||||
p := projectFromRow(row)
|
p := m.projectFromRow(row)
|
||||||
if row.Kind.WithDefault() == domain.ProjectKindWorkspace {
|
if row.Kind.WithDefault() == domain.ProjectKindWorkspace {
|
||||||
repos, err := m.store.ListWorkspaceRepos(ctx, row.ID)
|
repos, err := m.store.ListWorkspaceRepos(ctx, row.ID)
|
||||||
if err != nil {
|
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 in.Config != nil {
|
||||||
if err := in.Config.Validate(); err != nil {
|
if err := in.Config.Validate(); err != nil {
|
||||||
return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil)
|
return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil)
|
||||||
}
|
}
|
||||||
config = *in.Config
|
projectConfig = *in.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
registeredAt := time.Now()
|
registeredAt := time.Now()
|
||||||
|
|
@ -189,7 +204,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
|
||||||
DisplayName: name,
|
DisplayName: name,
|
||||||
RegisteredAt: registeredAt,
|
RegisteredAt: registeredAt,
|
||||||
Kind: domain.ProjectKindSingleRepo,
|
Kind: domain.ProjectKindSingleRepo,
|
||||||
Config: config,
|
Config: projectConfig,
|
||||||
}
|
}
|
||||||
if in.AsWorkspace {
|
if in.AsWorkspace {
|
||||||
repos, err := prepareWorkspaceProject(ctx, path, domain.ProjectID(row.ID), registeredAt)
|
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")
|
return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register workspace project")
|
||||||
}
|
}
|
||||||
m.emitProjectAdded(row, projectCountBefore == 0)
|
m.emitProjectAdded(row, projectCountBefore == 0)
|
||||||
p := projectFromRow(row)
|
p := m.projectFromRow(row)
|
||||||
p.WorkspaceRepos = workspaceReposFromRecords(repos)
|
p.WorkspaceRepos = workspaceReposFromRecords(repos)
|
||||||
return p, nil
|
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")
|
return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project")
|
||||||
}
|
}
|
||||||
m.emitProjectAdded(row, projectCountBefore == 0)
|
m.emitProjectAdded(row, projectCountBefore == 0)
|
||||||
return projectFromRow(row), nil
|
return m.projectFromRow(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Service) activeProjectCount(ctx context.Context) (int, error) {
|
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 {
|
if err := m.store.UpsertProject(ctx, row); err != nil {
|
||||||
return Project{}, apierr.Internal("PROJECT_CONFIG_UPDATE_FAILED", "Failed to update project config")
|
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
|
// 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{
|
p := Project{
|
||||||
ID: domain.ProjectID(row.ID),
|
ID: domain.ProjectID(row.ID),
|
||||||
Name: displayName(row),
|
Name: displayName(row),
|
||||||
|
|
@ -373,6 +388,7 @@ func projectFromRow(row domain.ProjectRecord) Project {
|
||||||
Path: row.Path,
|
Path: row.Path,
|
||||||
Repo: row.RepoOriginURL,
|
Repo: row.RepoOriginURL,
|
||||||
DefaultBranch: row.Config.WithDefaults().DefaultBranch,
|
DefaultBranch: row.Config.WithDefaults().DefaultBranch,
|
||||||
|
Agent: string(m.defaultHarness),
|
||||||
}
|
}
|
||||||
if !row.Config.IsZero() {
|
if !row.Config.IsZero() {
|
||||||
cfg := row.Config
|
cfg := row.Config
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,9 @@ func TestManager_DefaultsWhenUnconfigured(t *testing.T) {
|
||||||
if got.Project.DefaultBranch != domain.DefaultBranchName {
|
if got.Project.DefaultBranch != domain.DefaultBranchName {
|
||||||
t.Fatalf("default branch = %q, want %q", 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 {
|
if got.Project.Config != nil {
|
||||||
t.Fatalf("unconfigured project should omit config, got %#v", got.Project.Config)
|
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) {
|
func TestManager_AddDetectsNonMainDefaultBranch(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
m := newManager(t)
|
m := newManager(t)
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,11 @@ type ClaimPRResult struct {
|
||||||
|
|
||||||
// ListPRs returns all PRs currently owned by a session, ordered for display.
|
// 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) {
|
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)
|
return nil, fmt.Errorf("get %s: %w", id, err)
|
||||||
} else if !ok {
|
}
|
||||||
|
if !ok {
|
||||||
return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
|
return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
|
||||||
}
|
}
|
||||||
return s.listPRFacts(ctx, id)
|
return s.listPRFacts(ctx, id)
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,9 @@ type Store interface {
|
||||||
// presence of any row is the marker; preserved_ref may be empty for clean
|
// presence of any row is the marker; preserved_ref may be empty for clean
|
||||||
// worktrees.
|
// worktrees.
|
||||||
ListSessionWorktrees(ctx context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error)
|
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
|
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 {
|
if err := m.lcm.MarkTerminated(ctx, id); err != nil {
|
||||||
return false, fmt.Errorf("kill %s: %w", id, err)
|
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 {
|
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
|
// 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 {
|
} else {
|
||||||
m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err)
|
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 {
|
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
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -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
|
// TestKill_OtherWorkspaceErrorStillFails: only the typed dirty refusal is a
|
||||||
// success-with-preserved-workspace; any other teardown failure keeps erroring.
|
// success-with-preserved-workspace; any other teardown failure keeps erroring.
|
||||||
func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) {
|
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)
|
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) {
|
func TestRestore_ReopensTerminal(t *testing.T) {
|
||||||
m, st, rt, _ := newManager()
|
m, st, rt, _ := newManager()
|
||||||
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"})
|
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
|
// TestRestoreAll_SkipsSessionsKilledBeforeShutdown verifies (c): a session
|
||||||
// the user killed BEFORE shutdown has no session_worktrees row and must NOT
|
// the user killed BEFORE shutdown has no session_worktrees row and must NOT
|
||||||
// be resurrected.
|
// 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
|
// TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a
|
||||||
// non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace
|
// non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace
|
||||||
// restore but before relaunching.
|
// restore but before relaunching.
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [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
Loading…
Reference in New Issue