Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions backend/internal/adapters/agent/agy/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package agy

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)

func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{})
if err != nil || len(cmd) == 0 {
return ports.AgentAuthStatusUnknown, err
}
return authprobe.CLIStatus(ctx, cmd[0], nil)
}
18 changes: 18 additions & 0 deletions backend/internal/adapters/agent/aider/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package aider

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)

func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{})
if err != nil || len(cmd) == 0 {
return ports.AgentAuthStatusUnknown, err
}
return authprobe.CLIStatus(ctx, cmd[0], nil)
}
18 changes: 18 additions & 0 deletions backend/internal/adapters/agent/amp/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package amp

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)

func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{})
if err != nil || len(cmd) == 0 {
return ports.AgentAuthStatusUnknown, err
}
return authprobe.CLIStatus(ctx, cmd[0], nil)
}
18 changes: 18 additions & 0 deletions backend/internal/adapters/agent/auggie/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package auggie

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)

func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{})
if err != nil || len(cmd) == 0 {
return ports.AgentAuthStatusUnknown, err
}
return authprobe.CLIStatus(ctx, cmd[0], nil)
}
96 changes: 96 additions & 0 deletions backend/internal/adapters/agent/authprobe/authprobe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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"},
}

// 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 := exec.CommandContext(probeCtx, binary, args...).CombinedOutput()
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, probeCtx.Err()
}
text := strings.ToLower(string(out))
if hasAny(text,
"not 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`,
) {
return ports.AgentAuthStatusUnauthorized, nil
}
if hasAny(text,
"logged in",
"authenticated",
"authorized",
"token valid",
"api key found",
"credentials found",
`"loggedin": true`,
`"loggedin":true`,
) {
return ports.AgentAuthStatusAuthorized, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, nil
}
return ports.AgentAuthStatusUnknown, nil
}

func hasAny(text string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(text, needle) {
return true
}
}
return false
}
18 changes: 18 additions & 0 deletions backend/internal/adapters/agent/autohand/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package autohand

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)

func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{})
if err != nil || len(cmd) == 0 {
return ports.AgentAuthStatusUnknown, err
}
return authprobe.CLIStatus(ctx, cmd[0], nil)
}
31 changes: 31 additions & 0 deletions backend/internal/adapters/agent/claudecode/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"runtime"
"strings"
"sync"
"time"

"github.com/google/uuid"

Expand Down Expand Up @@ -60,6 +61,7 @@ func New() *Plugin {

var _ adapters.Adapter = (*Plugin)(nil)
var _ ports.Agent = (*Plugin)(nil)
var _ ports.AgentAuthChecker = (*Plugin)(nil)

// Manifest returns the adapter's static self-description.
func (p *Plugin) Manifest() adapters.Manifest {
Expand Down Expand Up @@ -268,6 +270,35 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
return info, true, nil
}

// AuthStatus checks Claude Code's local authentication state without starting a
// session.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.claudeBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
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()
}
var status struct {
LoggedIn bool `json:"loggedIn"`
}
if json.Unmarshal(out, &status) == nil {
if status.LoggedIn {
return ports.AgentAuthStatusAuthorized, nil
}
return ports.AgentAuthStatusUnauthorized, nil
}
if err != nil {
return ports.AgentAuthStatusUnauthorized, nil
}
return ports.AgentAuthStatusUnknown, nil
}

// claudeSessionUUID maps an AO session id onto a stable Claude Code
// session UUID via UUIDv5 over a fixed namespace, so the same AO session
// always resolves to the same Claude session.
Expand Down
18 changes: 18 additions & 0 deletions backend/internal/adapters/agent/cline/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package cline

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)

func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{})
if err != nil || len(cmd) == 0 {
return ports.AgentAuthStatusUnknown, err
}
return authprobe.CLIStatus(ctx, cmd[0], nil)
}
44 changes: 42 additions & 2 deletions backend/internal/adapters/agent/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import (
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"

"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
Expand All @@ -34,6 +36,7 @@ func New() *Plugin {

var _ adapters.Adapter = (*Plugin)(nil)
var _ ports.Agent = (*Plugin)(nil)
var _ ports.AgentAuthChecker = (*Plugin)(nil)

// Manifest returns the adapter's static self-description.
func (p *Plugin) Manifest() adapters.Manifest {
Expand Down Expand Up @@ -144,10 +147,37 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
return info, true, nil
}

// AuthStatus checks Codex's local login state without making a model call.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.codexBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()

out, err := exec.CommandContext(probeCtx, binary, "login", "status").CombinedOutput()
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, probeCtx.Err()
}
text := strings.ToLower(string(out))
if strings.Contains(text, "not logged in") || strings.Contains(text, "logged out") {
return ports.AgentAuthStatusUnauthorized, nil
}
if strings.Contains(text, "logged in") {
return ports.AgentAuthStatusAuthorized, nil
}
if err != nil {
return ports.AgentAuthStatusUnauthorized, nil
}
return ports.AgentAuthStatusUnknown, nil
}

// ResolveCodexBinary returns the path to the codex binary on this machine,
// searching PATH then a handful of well-known install locations
// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback
// so callers see a clear "command not found" rather than an empty argv.
// (Homebrew, Cargo, npm global, NVM). Returns "codex" as a last-ditch
// fallback so callers see a clear "command not found" rather than an empty
// argv.
func ResolveCodexBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
Expand Down Expand Up @@ -199,6 +229,7 @@ func ResolveCodexBinary(ctx context.Context) (string, error) {
filepath.Join(home, ".cargo", "bin", "codex"),
filepath.Join(home, ".npm", "bin", "codex"),
)
candidates = append(candidates, nvmNodeBinCandidates(home, "codex")...)
}

for _, candidate := range candidates {
Expand All @@ -213,6 +244,15 @@ func ResolveCodexBinary(ctx context.Context) (string, error) {
return "", fmt.Errorf("codex: %w", ports.ErrAgentBinaryNotFound)
}

func nvmNodeBinCandidates(home, binary string) []string {
matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin", binary))
if err != nil || len(matches) == 0 {
return nil
}
sort.Sort(sort.Reverse(sort.StringSlice(matches)))
return matches
}

func (p *Plugin) codexBinary(ctx context.Context) (string, error) {
p.binaryMu.Lock()
defer p.binaryMu.Unlock()
Expand Down
22 changes: 22 additions & 0 deletions backend/internal/adapters/agent/codex/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@ func TestGetLaunchCommandWithoutWorkspaceOmitsTrustFlag(t *testing.T) {
}
}

func TestResolveCodexBinaryFindsNVMInstallWhenPathIsSparse(t *testing.T) {
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", "")

got, err := ResolveCodexBinary(context.Background())
if err != nil {
t.Fatalf("ResolveCodexBinary: %v", err)
}
if got != want {
t.Fatalf("ResolveCodexBinary = %q, want %q", got, want)
}
}

func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
tests := []struct {
name string
Expand Down
18 changes: 18 additions & 0 deletions backend/internal/adapters/agent/continueagent/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package continueagent

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)

func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{})
if err != nil || len(cmd) == 0 {
return ports.AgentAuthStatusUnknown, err
}
return authprobe.CLIStatus(ctx, cmd[0], nil)
}
Loading