Skip to content
Open
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)
}
42 changes: 40 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 @@ -146,10 +149,37 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
return info, true, nil
}

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

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

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

for _, candidate := range candidates {
Expand All @@ -217,6 +248,13 @@ 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

`nvmNodeBinCandidates` is missing its closing brace — the function body never terminates before `func resolveNativeWindowsCodex(...)` starts on the next line, which nests a named function declaration inside another function. This is not valid Go and the package will not compile as currently pushed:

```go
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 { // <-- missing } above


Needs a `}` after `return matches` to close `nvmNodeBinCandidates` before `resolveNativeWindowsCodex` starts. Given `TestResolveCodexBinaryFindsNVMInstallWhenPathIsSparse` was added in this same PR, this file couldn't have been built/tested in its current state — worth double-checking the push matches what was actually tested locally.

func resolveNativeWindowsCodex(path string) string {
if runtime.GOOS != "windows" || !strings.EqualFold(filepath.Ext(path), ".cmd") {
return path
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 @@ -90,6 +90,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