diff --git a/.github/workflows/frontend-nightly.yml b/.github/workflows/frontend-nightly.yml index b58e1524c..7ad149c8f 100644 --- a/.github/workflows/frontend-nightly.yml +++ b/.github/workflows/frontend-nightly.yml @@ -9,6 +9,11 @@ on: schedule: - cron: "30 13 * * *" # 13:30 UTC = 19:00 IST daily workflow_dispatch: + inputs: + important: + description: 'Mark this nightly as an important update (escalates the in-app restart prompt). Retro-flag an already-published nightly by re-running only the publish-feed job with this input set.' + type: boolean + default: false jobs: guard: @@ -85,7 +90,17 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish - run: npm run publish + shell: bash + run: | + # Retry transient macOS sign/notary flakes (Apple notary -1009, + # osx-sign keychain races) so the build self-heals instead of failing. + for i in 1 2 3; do + if npm run publish; then exit 0; fi + echo "::warning::publish attempt $i/3 failed (likely a transient macOS sign/notary flake)" + [ "$i" -lt 3 ] && sleep 30 + done + echo "::error::publish failed after 3 attempts" >&2 + exit 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AO_RELEASE_REPO: ${{ github.repository }} @@ -93,15 +108,15 @@ jobs: # macOS signing + notarization env is exported by the "macOS signing # setup" step above via $GITHUB_ENV; the cert is in the keychain. - # Dedicated Intel (darwin-x64) nightly build leg, decoupled from the release - # matrix so the scarce macos-13 runner does not block publish-feed. Mirrors - # the release job's macOS steps exactly. publish-feed stays needs: release - # only (three fast legs); the x64 feed entry appears if this job finishes - # first, but the feed is not gated on it. + # Dedicated Intel (darwin-x64) nightly build leg. Kept separate from the + # release matrix (only a macOS host can build the x64 installer); publish-feed + # now waits on it so the x64 entry is present in nightly-mac.yml and Intel + # nightly users get auto-updates. Mirrors the release job's macOS steps exactly. + # Runner is macos-15-intel (macos-13 deprecated/no capacity; see release wf). release-intel: needs: guard if: needs.guard.outputs.should_build == 'true' - runs-on: macos-13 + runs-on: macos-15-intel permissions: contents: write defaults: @@ -135,7 +150,17 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish - run: npm run publish + shell: bash + run: | + # Retry transient macOS sign/notary flakes (Apple notary -1009, + # osx-sign keychain races) so the build self-heals instead of failing. + for i in 1 2 3; do + if npm run publish; then exit 0; fi + echo "::warning::publish attempt $i/3 failed (likely a transient macOS sign/notary flake)" + [ "$i" -lt 3 ] && sleep 30 + done + echo "::error::publish failed after 3 attempts" >&2 + exit 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AO_RELEASE_REPO: ${{ github.repository }} @@ -161,7 +186,7 @@ jobs: # assets. The nightly version is only stamped in-memory on the build runners, # so derive the tag from the guard job's computed version, not package.json. publish-feed: - needs: [guard, release] + needs: [guard, release, release-intel] runs-on: ubuntu-latest permissions: contents: write @@ -181,6 +206,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NIGHTLY_VERSION: ${{ needs.guard.outputs.version }} + NIGHTLY_IMPORTANT: ${{ inputs.important || 'false' }} run: | # Forge stamps package.json to VERSION without +build metadata and # publishes to v. Match it. @@ -188,7 +214,9 @@ jobs: tag="v$version" mkdir -p dist gh release download "$tag" --dir dist --clobber - node scripts/feed.mjs dist "$version" nightly + important_flag="" + [ "$NIGHTLY_IMPORTANT" = "true" ] && important_flag="--important" + node scripts/feed.mjs dist "$version" nightly $important_flag shopt -s nullglob assets=(dist/nightly*.yml dist/*.blockmap) if [ ${#assets[@]} -eq 0 ]; then echo "no feed assets generated" >&2; exit 1; fi diff --git a/.github/workflows/frontend-release.yml b/.github/workflows/frontend-release.yml index 93b8361a8..e588a7f0d 100644 --- a/.github/workflows/frontend-release.yml +++ b/.github/workflows/frontend-release.yml @@ -41,6 +41,11 @@ jobs: matrix: os: [macos-latest, windows-latest, ubuntu-latest] runs-on: ${{ matrix.os }} + # Gates the signed release behind the `release` environment's required + # reviewers: any write-access user can push a desktop-v* tag, but only + # designated approvers can let the build that consumes the signing secrets + # proceed. Configure reviewers in repo Settings > Environments > release. + environment: release permissions: contents: write defaults: @@ -71,7 +76,17 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish - run: npm run publish + shell: bash + run: | + # Retry transient macOS sign/notary flakes (Apple notary -1009, + # osx-sign keychain races) so the build self-heals instead of failing. + for i in 1 2 3; do + if npm run publish; then exit 0; fi + echo "::warning::publish attempt $i/3 failed (likely a transient macOS sign/notary flake)" + [ "$i" -lt 3 ] && sleep 30 + done + echo "::error::publish failed after 3 attempts" >&2 + exit 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AO_RELEASE_REPO: ${{ github.repository }} @@ -87,7 +102,7 @@ jobs: # release the publish step just created, with --clobber (so re-runs replace). # # Arch-coverage note: each runner only builds its host arch. macos-latest - # (Apple silicon) produces darwin-arm64; macos-13 (Intel x64) produces + # (Apple silicon) produces darwin-arm64; macos-15-intel (Intel x64) produces # darwin-x64. Both macOS runners run this step (see the `if` below), so the # arm64 and x64 stable aliases are now both built (spec §8). # The Linux stable name is undecided (spec §11.3), so we build the deb/rpm @@ -150,13 +165,15 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Dedicated Intel (darwin-x64) build leg, decoupled from the release matrix so - # the scarce macos-13 runner does not block publish-feed. publish-feed needs - # only the three fast legs (release); this job uploads the x64 installer and - # stable alias independently. feed.mjs includes the x64 entry if this job - # finishes before publish-feed, but the feed is not gated on it. + # Dedicated Intel (darwin-x64) build leg. Kept a separate job (not in the + # release matrix) because only a macOS host can build the x64 installer, but + # publish-feed now waits on it (needs: [release, release-intel]) so the x64 + # entry is always present in latest-mac.yml and Intel users get auto-updates. + # Runner is macos-15-intel: macos-13 is deprecated/has no capacity (multi-hour + # queues), so this leg uses the supported Intel x64 image. release-intel: - runs-on: macos-13 + runs-on: macos-15-intel + environment: release # same approval gate as the matrix release job permissions: contents: write defaults: @@ -184,7 +201,17 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish - run: npm run publish + shell: bash + run: | + # Retry transient macOS sign/notary flakes (Apple notary -1009, + # osx-sign keychain races) so the build self-heals instead of failing. + for i in 1 2 3; do + if npm run publish; then exit 0; fi + echo "::warning::publish attempt $i/3 failed (likely a transient macOS sign/notary flake)" + [ "$i" -lt 3 ] && sleep 30 + done + echo "::error::publish failed after 3 attempts" >&2 + exit 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AO_RELEASE_REPO: ${{ github.repository }} @@ -210,7 +237,7 @@ jobs: # assets and upload them to the same release. Runs once, on Linux: it only # hashes already-built artifacts, so it needs no per-OS runner. publish-feed: - needs: release + needs: [release, release-intel] runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index b9a10b8df..5dbea5388 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -1,11 +1,10 @@ name: Prettier -# Auto-formats the codebase on every push and commits the result back. -# Formatting is a CI concern — developers never need to run Prettier locally -# and formatted output never shows up as local uncommitted changes. -# -# GitHub Actions does not re-trigger workflows on commits made with GITHUB_TOKEN, -# so there is no feedback loop risk. +# Check-only: reports formatting issues without modifying the branch. +# The job fails (and annotates unformatted files) when `prettier --check` +# finds files that are not formatted, but it never writes, commits, or +# pushes anything back to the PR. Developers run `npx prettier@3 --write .` +# locally to fix the reported files. on: push: @@ -18,21 +17,9 @@ jobs: format: runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - token: ${{ secrets.GITHUB_TOKEN }} - - name: Format with Prettier - run: npx --yes prettier@3 --write . - - - name: Commit formatted files - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git diff --quiet && exit 0 - git add -A - git commit -m "chore: format with prettier [skip ci]" - git push + - name: Check formatting with Prettier + run: npx --yes prettier@3 --check . diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..7dbc0eca8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +We love contributions! Join our community on Discord to get started. + +## Join us on Discord + +[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white&logoSize=auto)](https://discord.com/invite/UZv7JjxbwG) + +**Daily contributor sync:** Every day at **10:00 PM IST** + +Get your issues verified by core contributors, ask questions, share progress, and learn from the community. New contributors are always welcome! + +**Why join Discord?** + +- Get your issues and PRs verified by core contributors before investing time +- Learn from experienced contributors in daily sync calls +- Share your progress and get feedback +- Get help troubleshooting in real-time +- Stay updated on the latest developments and roadmap + +## Quick Start + +1. **Join the Discord** - Connect with the community and get guidance +2. **Read the contributor contract** - See [AGENTS.md](AGENTS.md) for repo layout, daemon/API boundaries, and coding conventions +3. **Pick a focused problem** - Browse [open issues](https://github.com/AgentWrapper/agent-orchestrator/issues) and choose one small enough for a focused PR +4. **Open a clear PR** - Keep changes narrow, explain user-visible impact, link issues, include tests +5. **Iterate with contributors** - Use review feedback to tighten the PR until verified diff --git a/README.md b/README.md index 0faa318d3..08b3f42bd 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,49 @@ An Agentic IDE that supervises parallel AI coding agents in isolated workspaces, ![Agent Orchestrator Dashboard](ao-dashboard-preview.png) + + +--- + +## What is Agent Orchestrator? + +Agent Orchestrator is a meta-harness agent IDE for running AI coding agents in parallel. It gives terminal-based agents like Claude Code, Codex, Cursor, Aider, Goose, and others a shared workspace where their sessions, terminals, branches, pull requests, and feedback loops can be supervised from one place. + +The agents still do the coding. AO provides the harness around them: isolated workspaces, live terminal access, session state, PR awareness, and automatic loops that send CI failures, review comments, and merge conflicts back to the right agent. Instead of manually coordinating a pile of agent terminals, AO turns parallel agent work into a managed workflow. + +--- + +## Why Agent Orchestrator? + +AI coding agents become much more useful when they can work in parallel, but parallel work gets messy quickly. Branches overlap, terminals get lost, CI failures need follow-up, review comments need replies, and merge conflicts have to reach the right worker. + +Agent Orchestrator is built to keep that loop visible and manageable. It helps you: + +- Start multiple agents from the same project without mixing their work +- Keep every session in a separate git worktree +- See which agents are working, waiting, finished, or blocked +- Route CI failures, review comments, and merge conflicts back to the right session +- Use different agent CLIs through one common supervisor + +--- + +## How it works + +At a high level, Agent Orchestrator follows a simple loop: + +1. Add a project you want agents to work on. +2. Start one or more sessions from the desktop app or CLI. +3. AO creates an isolated git worktree for each session. +4. AO launches the selected coding agent in that session's terminal runtime. +5. The local daemon watches session state, terminal activity, pull requests, CI, and review feedback. +6. The desktop app and CLI show the current state and let you send follow-up instructions to the right session. + +The result is a local control layer for agentic coding: agents still do the coding, while Agent Orchestrator keeps their workspaces, status, terminals, and feedback loops organized. + +--- + +
+ ### Witness AO's Journey on X @@ -41,7 +84,7 @@ An Agentic IDE that supervises parallel AI coding agents in isolated workspaces,
-[Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Documentation](#documentation) • [Contributing](#contributing) +[What is Agent Orchestrator?](#what-is-agent-orchestrator) • [Why Agent Orchestrator?](#why-agent-orchestrator) • [How it works](#how-it-works) • [Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Documentation](#documentation) • [Contributing](#contributing)
@@ -65,6 +108,9 @@ An Agentic IDE that supervises parallel AI coding agents in isolated workspaces, Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Cursor, OpenCode, Aider, Amp, Goose, GitHub Copilot, Grok, Qwen Code, Kimi Code, Crush, Cline, Droid, Devin, Auggie, Continue, Kiro, and Kilo Code. +For direct CLI usage, including agent readiness checks and context-aware session +spawns, see the [CLI Guide](docs/cli/README.md). + **If it runs in a terminal, it runs on Agent Orchestrator.** --- @@ -129,8 +175,8 @@ For detailed architecture diagrams, data flows, and load-bearing rules, see [arc | -------------------------------------------------------- | ------------------------------------------------------- | | [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules | | [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules | +| [CLI Guide](docs/cli/README.md) | Direct `ao` CLI usage, command routes, and smoke tests | | [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract | -| [Agent Adapter Contract](docs/agent/README.md) | Agent adapter interface and hook behavior | --- diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index 812e15e29..9e849e0bd 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -9,19 +9,12 @@ package activitydispatch import ( + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agy" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/autohand" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cline" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/goose" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kilocode" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kiro" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/qwen" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) @@ -32,19 +25,21 @@ type DeriveFunc func(event string, payload []byte) (domain.ActivityState, bool) // Derivers maps the agent token in `ao hooks ` to its deriver. // Per-adapter PRs add their tokens here as they land. var Derivers = map[string]DeriveFunc{ + // Adapters that parse hook payloads for finer-grained state keep their own + // deriver; the rest share the name-only StandardDeriveActivityState. "claude-code": claudecode.DeriveActivityState, "codex": codex.DeriveActivityState, - "cursor": cursor.DeriveActivityState, - "opencode": opencode.DeriveActivityState, - "qwen": qwen.DeriveActivityState, - "copilot": copilot.DeriveActivityState, "droid": droid.DeriveActivityState, "agy": agy.DeriveActivityState, - "goose": goose.DeriveActivityState, - "cline": cline.DeriveActivityState, - "kiro": kiro.DeriveActivityState, - "kilocode": kilocode.DeriveActivityState, - "autohand": autohand.DeriveActivityState, + "opencode": opencode.DeriveActivityState, + "goose": activitystate.StandardDeriveActivityState, + "cursor": activitystate.StandardDeriveActivityState, + "qwen": activitystate.StandardDeriveActivityState, + "copilot": activitystate.StandardDeriveActivityState, + "cline": activitystate.StandardDeriveActivityState, + "kiro": activitystate.StandardDeriveActivityState, + "kilocode": activitystate.StandardDeriveActivityState, + "autohand": activitystate.StandardDeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when diff --git a/backend/internal/adapters/agent/activitystate/activitystate.go b/backend/internal/adapters/agent/activitystate/activitystate.go new file mode 100644 index 000000000..3c5375d9a --- /dev/null +++ b/backend/internal/adapters/agent/activitystate/activitystate.go @@ -0,0 +1,32 @@ +// Package activitystate holds the standard mapping from an AO hook sub-command +// name onto an activity state. Most adapters install the same +// session-start/user-prompt-submit/stop/permission-request callbacks and derive +// activity identically from the event name alone; they share this deriver rather +// than each carrying a copy. Adapters that inspect the hook payload for finer +// grained state (claude-code, codex, droid) keep their own deriver. +package activitystate + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// StandardDeriveActivityState maps a hook sub-command name onto an AO activity +// state. The bool is false when the event carries no activity signal. The +// payload is ignored: this is the name-only mapping shared by adapters whose +// hooks report activity purely through which callback fired. +// +// - session-start / user-prompt-submit → active +// - stop → idle +// - permission-request → waiting_input +func StandardDeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/activitystate/activitystate_test.go b/backend/internal/adapters/agent/activitystate/activitystate_test.go new file mode 100644 index 000000000..3fe8f91b9 --- /dev/null +++ b/backend/internal/adapters/agent/activitystate/activitystate_test.go @@ -0,0 +1,28 @@ +package activitystate + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestStandardDeriveActivityState(t *testing.T) { + cases := []struct { + event string + want domain.ActivityState + wantOK bool + }{ + {"session-start", domain.ActivityActive, true}, + {"user-prompt-submit", domain.ActivityActive, true}, + {"stop", domain.ActivityIdle, true}, + {"permission-request", domain.ActivityWaitingInput, true}, + {"unknown", "", false}, + {"", "", false}, + } + for _, tc := range cases { + got, ok := StandardDeriveActivityState(tc.event, []byte("ignored")) + if got != tc.want || ok != tc.wantOK { + t.Errorf("event %q: got (%q, %v), want (%q, %v)", tc.event, got, ok, tc.want, tc.wantOK) + } + } +} diff --git a/backend/internal/adapters/agent/agentbase/agentbase.go b/backend/internal/adapters/agent/agentbase/agentbase.go new file mode 100644 index 000000000..7c1a302a7 --- /dev/null +++ b/backend/internal/adapters/agent/agentbase/agentbase.go @@ -0,0 +1,69 @@ +// Package agentbase supplies the defaults an agent adapter would otherwise +// hand-copy. Most adapters implement several ports.Agent methods identically: +// no config keys, prompt delivered in the launch command, and (for the simpler +// harnesses) no hooks, no resume, no session metadata. Embedding Base gives an +// adapter those defaults so it only writes the methods it actually customizes. +package agentbase + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Base provides no-op defaults for the optional ports.Agent methods. Embed it in +// a Plugin struct (`agentbase.Base`) and override only what the harness needs. +// Every method honors ctx cancellation and otherwise does nothing, matching what +// the adapters previously wrote by hand. +type Base struct{} + +// GetConfigSpec reports no agent-specific config keys. +func (Base) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, ctx.Err() +} + +// GetPromptDeliveryStrategy reports that the agent receives its prompt in the +// launch command itself, which is true for every shipped adapter. +func (Base) GetPromptDeliveryStrategy(ctx context.Context, _ ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks is a no-op for harnesses without a native hook surface. +func (Base) GetAgentHooks(ctx context.Context, _ ports.WorkspaceHookConfig) error { + return ctx.Err() +} + +// GetRestoreCommand reports that no existing native session can be continued. +func (Base) GetRestoreCommand(ctx context.Context, _ ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + return nil, false, nil +} + +// SessionInfo reports no agent-owned session metadata. +func (Base) SessionInfo(ctx context.Context, _ ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + return ports.SessionInfo{}, false, nil +} + +// StandardSessionInfo returns the normalized session metadata (native session +// id, title, summary) an adapter's hooks persisted under the shared +// ports.MetadataKey* keys. ok is false when none of the three is present. An +// adapter whose SessionInfo just reads those keys delegates here. +func StandardSessionInfo(session ports.SessionRef) (ports.SessionInfo, bool) { + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[ports.MetadataKeyTitle], + Summary: session.Metadata[ports.MetadataKeySummary], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false + } + return info, true +} diff --git a/backend/internal/adapters/agent/agy/agy.go b/backend/internal/adapters/agent/agy/agy.go index e29563bad..c527cc125 100644 --- a/backend/internal/adapters/agent/agy/agy.go +++ b/backend/internal/adapters/agent/agy/agy.go @@ -5,30 +5,34 @@ package agy import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - adapterID = "agy" +const adapterID = "agy" - // Normalized session-metadata keys. Shared vocabulary with the Codex and Claude Code - // adapters so the dashboard treats every agent uniformly. - agyTitleMetadataKey = "title" - agySummaryMetadataKey = "summary" -) +var agyBinarySpec = binaryutil.BinarySpec{ + Label: "agy", + Names: []string{"agy"}, + WinNames: []string{"agy.cmd", "agy.exe", "agy"}, + UnixPaths: []string{"/usr/local/bin/agy", "/opt/homebrew/bin/agy"}, + UnixHomePaths: [][]string{{".local", "bin", "agy"}, {".cargo", "bin", "agy"}, {".npm", "bin", "agy"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "agy.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "agy.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "agy.exe"}}, + }, +} // Plugin is the Agy agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.RWMutex resolvedBinary string } @@ -54,14 +58,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Agy exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start an interactive Agy session. // Shape: // @@ -89,15 +85,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Agy receives its prompt in the -// launch command itself via --prompt-interactive. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Agy session: // `agy --add-dir [--dangerously-skip-permissions] --conversation `. func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { @@ -134,84 +121,15 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[agyTitleMetadataKey], - Summary: session.Metadata[agySummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveAgyBinary returns the path to the agy binary on this machine, -// searching PATH then a handful of well-known install locations. -// Returns "agy" as a last-ditch fallback. +// searching PATH then a handful of well-known install locations. It returns a +// wrapped ports.ErrAgentBinaryNotFound when agy is absent. func ResolveAgyBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"agy.cmd", "agy.exe", "agy"} { - path, err := exec.LookPath(name) - if err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "agy.cmd"), - filepath.Join(appData, "npm", "agy.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "agy.exe")) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("agy: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("agy"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/agy", - "/opt/homebrew/bin/agy", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "agy"), - filepath.Join(home, ".cargo", "bin", "agy"), - filepath.Join(home, ".npm", "bin", "agy"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("agy: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, agyBinarySpec) } func (p *Plugin) agyBinary(ctx context.Context) (string, error) { @@ -238,8 +156,3 @@ func (p *Plugin) agyBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/agy/agy_test.go b/backend/internal/adapters/agent/agy/agy_test.go index b51205003..eba2c0d29 100644 --- a/backend/internal/adapters/agent/agy/agy_test.go +++ b/backend/internal/adapters/agent/agy/agy_test.go @@ -43,7 +43,7 @@ func TestGetLaunchCommand(t *testing.T) { } func TestGetPromptDeliveryStrategy(t *testing.T) { - plugin := &Plugin{resolvedBinary: "agy"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { t.Fatal(err) @@ -200,3 +200,15 @@ func TestHooksLifecycle(t *testing.T) { t.Fatal("expected hooks to be uninstalled after UninstallHooks") } } + +func TestAuthStatus(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + + status, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Errorf("AuthStatus() = %v, want AgentAuthStatusAuthorized", status) + } +} diff --git a/backend/internal/adapters/agent/agy/auth.go b/backend/internal/adapters/agent/agy/auth.go new file mode 100644 index 000000000..658519a7d --- /dev/null +++ b/backend/internal/adapters/agent/agy/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/agy/install.go b/backend/internal/adapters/agent/agy/install.go new file mode 100644 index 000000000..223e7448d --- /dev/null +++ b/backend/internal/adapters/agent/agy/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/aider/aider.go b/backend/internal/adapters/agent/aider/aider.go index e813b88a2..148885d44 100644 --- a/backend/internal/adapters/agent/aider/aider.go +++ b/backend/internal/adapters/agent/aider/aider.go @@ -18,6 +18,8 @@ import ( "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -26,6 +28,7 @@ const adapterID = "aider" // Plugin is the Aider agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -51,14 +54,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a headless Aider session: // // aider -m [permission flags] --no-check-update --no-stream --no-pretty [--read ] @@ -91,40 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Aider receives its prompt in the launch -// command itself (via -m). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is a no-op: Aider emits no lifecycle hooks (Tier C), so there -// is no native hook config to install AO hooks into. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - -// GetRestoreCommand always reports that no native session can be continued. -// Aider has no native session id or resume-by-id mechanism -// (see github.com/Aider-AI/aider issues/166), so the manager always falls back -// to a fresh launch. -func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { - if err := ctx.Err(); err != nil { - return nil, false, err - } - return nil, false, nil -} - -// SessionInfo is a no-op: Aider exposes no captureable session metadata. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil -} - // normalizePermissionMode collapses an empty mode onto PermissionModeDefault so // callers can switch over a stable set of values. func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { @@ -190,7 +151,7 @@ func ResolveAiderBinary(ctx context.Context) (string, error) { } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -216,8 +177,3 @@ func (p *Plugin) aiderBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/aider/aider_test.go b/backend/internal/adapters/agent/aider/aider_test.go index 72bcaffdf..9a32707e2 100644 --- a/backend/internal/adapters/agent/aider/aider_test.go +++ b/backend/internal/adapters/agent/aider/aider_test.go @@ -217,7 +217,7 @@ func TestGetLaunchCommandInlineSystemPromptIsDropped(t *testing.T) { } func TestGetRestoreCommandAlwaysFalse(t *testing.T) { - p := &Plugin{resolvedBinary: "aider"} + p := &Plugin{} cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ Session: ports.SessionRef{ Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abc123"}, diff --git a/backend/internal/adapters/agent/aider/auth.go b/backend/internal/adapters/agent/aider/auth.go new file mode 100644 index 000000000..e06b090ca --- /dev/null +++ b/backend/internal/adapters/agent/aider/auth.go @@ -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 "" +} diff --git a/backend/internal/adapters/agent/aider/auth_test.go b/backend/internal/adapters/agent/aider/auth_test.go new file mode 100644 index 000000000..6635c7b9f --- /dev/null +++ b/backend/internal/adapters/agent/aider/auth_test.go @@ -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, "") + } +} diff --git a/backend/internal/adapters/agent/aider/install.go b/backend/internal/adapters/agent/aider/install.go new file mode 100644 index 000000000..6b86b07b1 --- /dev/null +++ b/backend/internal/adapters/agent/aider/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/amp/amp.go b/backend/internal/adapters/agent/amp/amp.go index 65c3512a8..4f0580744 100644 --- a/backend/internal/adapters/agent/amp/amp.go +++ b/backend/internal/adapters/agent/amp/amp.go @@ -8,15 +8,12 @@ package amp import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -25,6 +22,7 @@ const adapterID = "amp" // Plugin is the Amp agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -50,14 +48,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new interactive Amp session: // // amp [--permission-mode ] [--append-system-prompt ] [-- ] @@ -87,21 +77,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Amp receives its prompt in the launch -// command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is intentionally a no-op until Amp activity can be reported via -// an Amp-specific plugin. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - // GetRestoreCommand rebuilds the argv that continues an existing Amp session // when plugin-derived native session metadata is available. Until that metadata // exists, ok is false and callers fall back to fresh launch behavior. @@ -126,14 +101,6 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo is intentionally a no-op until Amp plugin metadata exists. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil -} - func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) { switch mode { case ports.PermissionModeAcceptEdits: @@ -145,66 +112,22 @@ func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) { } } +var ampBinarySpec = binaryutil.BinarySpec{ + Label: "amp", + Names: []string{"amp"}, + WinNames: []string{"amp.cmd", "amp.exe", "amp"}, + UnixPaths: []string{"/usr/local/bin/amp", "/opt/homebrew/bin/amp"}, + UnixHomePaths: [][]string{{".local", "bin", "amp"}, {".npm", "bin", "amp"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.exe"}}, + }, +} + // ResolveAmpBinary finds the `amp` binary, searching PATH then common install -// locations. It returns "amp" as a last resort so callers get the shell's normal -// command-not-found behavior if Amp is absent. +// locations. It returns a wrapped ports.ErrAgentBinaryNotFound when Amp is absent. func ResolveAmpBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"amp.cmd", "amp.exe", "amp"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "amp.cmd"), - filepath.Join(appData, "npm", "amp.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("amp"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/amp", - "/opt/homebrew/bin/amp", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "amp"), - filepath.Join(home, ".npm", "bin", "amp"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, ampBinarySpec) } func (p *Plugin) ampBinary(ctx context.Context) (string, error) { @@ -222,8 +145,3 @@ func (p *Plugin) ampBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/amp/auth.go b/backend/internal/adapters/agent/amp/auth.go new file mode 100644 index 000000000..57e7c18a8 --- /dev/null +++ b/backend/internal/adapters/agent/amp/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/amp/auth_test.go b/backend/internal/adapters/agent/amp/auth_test.go new file mode 100644 index 000000000..662e9ed7c --- /dev/null +++ b/backend/internal/adapters/agent/amp/auth_test.go @@ -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 } +} diff --git a/backend/internal/adapters/agent/amp/install.go b/backend/internal/adapters/agent/amp/install.go new file mode 100644 index 000000000..20a45a9f5 --- /dev/null +++ b/backend/internal/adapters/agent/amp/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/auggie/auggie.go b/backend/internal/adapters/agent/auggie/auggie.go index e74dc78d5..71ddf5a17 100644 --- a/backend/internal/adapters/agent/auggie/auggie.go +++ b/backend/internal/adapters/agent/auggie/auggie.go @@ -4,7 +4,7 @@ // // Auggie is Augment Code's terminal coding agent (binary "auggie", installed via // `npm install -g @augmentcode/auggie`). It exposes a headless one-shot mode via -// `--print` (alias `-p`) which runs a single instruction and exits — the mode AO +// `--print` (alias `-p`) which runs a single instruction and exits -- the mode AO // uses to drive it unattended. // // Launch shape: @@ -38,15 +38,12 @@ package auggie import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -55,6 +52,7 @@ const adapterID = "auggie" // Plugin is the Auggie agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -80,14 +78,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new headless Auggie session: // // auggie --print [--instruction-file | --instruction ] [-- ] @@ -116,22 +106,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Auggie receives its prompt in the launch -// command itself (the print-mode positional). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is intentionally a no-op: Auggie has no hook or lifecycle event -// system, so there is nothing to install. Activity reporting will require an -// Auggie-specific integration once one exists. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - // GetRestoreCommand rebuilds the argv that continues an existing Auggie session // when a native session id is available in metadata: // @@ -156,81 +130,28 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo is intentionally a no-op until Auggie session metadata can be -// captured (Auggie exposes no hook surface to derive it from). -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil -} - // Auggie has no single blanket auto-approve/bypass flag; unattended tool/file // approval is governed by granular `--permission :` rules, so // AO emits no approval flag and defers every mode to the user's Auggie config. // There is therefore no appendApprovalFlags helper for this adapter. +var auggieBinarySpec = binaryutil.BinarySpec{ + Label: "auggie", + Names: []string{"auggie"}, + WinNames: []string{"auggie.cmd", "auggie.exe", "auggie"}, + UnixPaths: []string{"/usr/local/bin/auggie", "/opt/homebrew/bin/auggie"}, + UnixHomePaths: [][]string{{".local", "bin", "auggie"}, {".npm", "bin", "auggie"}, {".npm-global", "bin", "auggie"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "auggie.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "auggie.exe"}}, + }, +} + // ResolveAuggieBinary finds the `auggie` binary, searching PATH then common // install locations. It returns "auggie" as a last resort so callers get the // shell's normal command-not-found behavior if Auggie is absent. func ResolveAuggieBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"auggie.cmd", "auggie.exe", "auggie"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "auggie.cmd"), - filepath.Join(appData, "npm", "auggie.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("auggie: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("auggie"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/auggie", - "/opt/homebrew/bin/auggie", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "auggie"), - filepath.Join(home, ".npm", "bin", "auggie"), - filepath.Join(home, ".npm-global", "bin", "auggie"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("auggie: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, auggieBinarySpec) } func (p *Plugin) auggieBinary(ctx context.Context) (string, error) { @@ -248,8 +169,3 @@ func (p *Plugin) auggieBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/auggie/auth.go b/backend/internal/adapters/agent/auggie/auth.go new file mode 100644 index 000000000..29ae23e1f --- /dev/null +++ b/backend/internal/adapters/agent/auggie/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/auggie/auth_test.go b/backend/internal/adapters/agent/auggie/auth_test.go new file mode 100644 index 000000000..2b204c5d7 --- /dev/null +++ b/backend/internal/adapters/agent/auggie/auth_test.go @@ -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 } +} diff --git a/backend/internal/adapters/agent/auggie/install.go b/backend/internal/adapters/agent/auggie/install.go new file mode 100644 index 000000000..2a605fdfe --- /dev/null +++ b/backend/internal/adapters/agent/auggie/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/authprobe/authprobe.go b/backend/internal/adapters/agent/authprobe/authprobe.go new file mode 100644 index 000000000..ea15f8201 --- /dev/null +++ b/backend/internal/adapters/agent/authprobe/authprobe.go @@ -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 +} diff --git a/backend/internal/adapters/agent/authprobe/authprobe_test.go b/backend/internal/adapters/agent/authprobe/authprobe_test.go new file mode 100644 index 000000000..0c8aa661c --- /dev/null +++ b/backend/internal/adapters/agent/authprobe/authprobe_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/adapters/agent/autohand/activity.go b/backend/internal/adapters/agent/autohand/activity.go deleted file mode 100644 index e1280f371..000000000 --- a/backend/internal/adapters/agent/autohand/activity.go +++ /dev/null @@ -1,26 +0,0 @@ -package autohand - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps an Autohand hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in autohandManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), routed -// from Autohand's native lifecycle events. Autohand has no SessionEnd/process- -// exit hook wired into the adapter, so runtime exit still falls back to the -// lifecycle reaper. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/autohand/auth.go b/backend/internal/adapters/agent/autohand/auth.go new file mode 100644 index 000000000..0471e8d31 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/auth.go @@ -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 + } +} diff --git a/backend/internal/adapters/agent/autohand/auth_test.go b/backend/internal/adapters/agent/autohand/auth_test.go new file mode 100644 index 000000000..6b0214911 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/auth_test.go @@ -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 +} diff --git a/backend/internal/adapters/agent/autohand/autohand.go b/backend/internal/adapters/agent/autohand/autohand.go index 988bc9ac2..90daf1f22 100644 --- a/backend/internal/adapters/agent/autohand/autohand.go +++ b/backend/internal/adapters/agent/autohand/autohand.go @@ -6,34 +6,27 @@ // command mode (`autohand -p ` / positional prompt), native session // resume (`autohand resume `), and a native hook/lifecycle system // whose events (session-start, stop, permission-request, ...) AO maps onto -// activity states. See hooks.go for hook installation and activity.go for the -// event→state mapping. +// activity states. See hooks.go for hook installation. package autohand import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - adapterID = "autohand" - - autohandTitleMetadataKey = "title" - autohandSummaryMetadataKey = "summary" -) +const adapterID = "autohand" // Plugin is the Autohand agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base + binaryMu sync.Mutex resolvedBinary string } @@ -59,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Autohand exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new Autohand command-mode session, // scoping the run to the workspace, applying the approval-mode flags and optional // system-prompt override, and passing the initial prompt as a positional argument @@ -98,15 +83,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Autohand receives its prompt in the -// launch command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Autohand // session: `autohand resume [--path ] `. ok is false when // the hook-derived native session id has not landed yet, so callers can fall @@ -139,15 +115,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[autohandTitleMetadataKey], - Summary: session.Metadata[autohandSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // appendWorkspaceFlag scopes the run to the given workspace path via --path. @@ -160,10 +129,10 @@ func appendWorkspaceFlag(cmd *[]string, workspacePath string) { // appendApprovalFlags maps AO's four permission modes onto Autohand's approval // flags. Default emits no flag so Autohand resolves its starting mode from the // user's own config (permissions.mode). Autohand has no distinct "accept-edits" -// mode, so it maps to --yes (auto-confirm risky actions) — the least-privileged -// non-interactive option — while auto/bypass map to --unrestricted. +// mode, so it maps to --yes (auto-confirm risky actions) -- the least-privileged +// non-interactive option -- while auto/bypass map to --unrestricted. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Autohand config/default behavior. case ports.PermissionModeAcceptEdits: @@ -175,85 +144,23 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } +var autohandBinarySpec = binaryutil.BinarySpec{ + Label: "autohand", + Names: []string{"autohand"}, + WinNames: []string{"autohand.cmd", "autohand.exe", "autohand"}, + UnixPaths: []string{"/usr/local/bin/autohand", "/opt/homebrew/bin/autohand"}, + UnixHomePaths: [][]string{{".local", "bin", "autohand"}, {".npm", "bin", "autohand"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".local", "bin", "autohand.exe"}}, + }, } -// ResolveAutohandBinary returns the path to the autohand binary on this machine, -// searching PATH then a handful of well-known install locations (Homebrew, the -// official ~/.local/bin installer, npm global). Returns "autohand" as a -// last-ditch fallback so callers see a clear "command not found" rather than an -// empty argv. +// ResolveAutohandBinary returns the path to the autohand binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveAutohandBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"autohand.cmd", "autohand.exe", "autohand"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "autohand.cmd"), - filepath.Join(appData, "npm", "autohand.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, ".local", "bin", "autohand.exe")) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("autohand"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/autohand", - "/opt/homebrew/bin/autohand", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "autohand"), - filepath.Join(home, ".npm", "bin", "autohand"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, autohandBinarySpec) } func (p *Plugin) autohandBinary(ctx context.Context) (string, error) { @@ -277,8 +184,3 @@ func (p *Plugin) autohandBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/autohand/autohand_test.go b/backend/internal/adapters/agent/autohand/autohand_test.go index 9e96bbfff..9387bd3e8 100644 --- a/backend/internal/adapters/agent/autohand/autohand_test.go +++ b/backend/internal/adapters/agent/autohand/autohand_test.go @@ -8,6 +8,7 @@ import ( "reflect" "testing" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -125,7 +126,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "autohand"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -137,7 +138,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "autohand"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -208,8 +209,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "sess-123", - autohandTitleMetadataKey: "Fix login redirect", - autohandSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -356,9 +357,9 @@ func TestDeriveActivityState(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, []byte(`{}`)) + got, ok := activitystate.StandardDeriveActivityState(tt.event, []byte(`{}`)) if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", + t.Fatalf("StandardDeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, got, ok, tt.want, tt.wantOK) } }) diff --git a/backend/internal/adapters/agent/autohand/hooks.go b/backend/internal/adapters/agent/autohand/hooks.go index 084515bba..1e579d423 100644 --- a/backend/internal/adapters/agent/autohand/hooks.go +++ b/backend/internal/adapters/agent/autohand/hooks.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -294,35 +295,12 @@ func writeAutohandHooks(configPath string, topLevel, hooksSection map[string]jso return fmt.Errorf("encode %s: %w", configPath, err) } data = append(data, '\n') - if err := atomicWriteFile(configPath, data, 0o600); err != nil { + if err := hookutil.AtomicWriteFile(configPath, data, 0o600); err != nil { return fmt.Errorf("write %s: %w", configPath, err) } return nil } -// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- -// write can't leave a truncated/empty config that Autohand then fails to parse. -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} - func isAutohandManagedHook(command string) bool { return strings.HasPrefix(command, autohandHookCommandPrefix) } diff --git a/backend/internal/adapters/agent/autohand/install.go b/backend/internal/adapters/agent/autohand/install.go new file mode 100644 index 000000000..62f06f0e3 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/binaryutil/binaryutil.go b/backend/internal/adapters/agent/binaryutil/binaryutil.go new file mode 100644 index 000000000..8016056e5 --- /dev/null +++ b/backend/internal/adapters/agent/binaryutil/binaryutil.go @@ -0,0 +1,134 @@ +// Package binaryutil centralizes the "find an agent's CLI binary" search that +// every adapter otherwise reimplements. Adapters differ only in the binary +// name(s) and the well-known install locations to probe, so they describe those +// with a BinarySpec and share the identical PATH-then-candidates iteration. +package binaryutil + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// BinarySpec describes where one agent's CLI binary can live. ResolveBinary +// searches PATH (via the platform's name list) first, then the platform's +// candidate install paths in order, returning the first hit. +// +// Path components are given as string slices joined onto their base directory, +// so a spec stays OS-agnostic and never hard-codes a separator. env-derived +// bases (APPDATA, LOCALAPPDATA, home) that are unset simply skip their +// candidates. +type BinarySpec struct { + // Label prefixes the ErrAgentBinaryNotFound error, e.g. "claude". + Label string + + // Names are the binary names looked up on PATH on non-Windows, in order. + Names []string + // WinNames are the binary names looked up on PATH on Windows, in order. + // Empty means the Windows branch does no PATH lookup. + WinNames []string + + // UnixPaths are absolute candidate paths probed on non-Windows, in order. + UnixPaths []string + // UnixHomePaths are candidate paths under the user's home dir on + // non-Windows; each entry is the components to join onto $HOME. + UnixHomePaths [][]string + + // WinPaths are candidate paths probed on Windows, in the exact order given. + // Each entry names the base directory (%APPDATA%, %LOCALAPPDATA%, or home) it + // is joined onto. Order is significant: a native installer location listed + // before an npm shim wins when both are present, so it is spelled out here + // rather than assumed. Entries whose base env is unset are skipped. + WinPaths []WinPath +} + +// WinBase names the base directory a Windows candidate path is joined onto. +type WinBase int + +// The base directories a Windows candidate path can resolve against. +const ( + WinAppData WinBase = iota // %APPDATA% + WinLocalAppData // %LOCALAPPDATA% + WinHome // the user's home directory +) + +// WinPath is one Windows candidate: Parts joined onto Base's directory. +type WinPath struct { + Base WinBase + Parts []string +} + +// ResolveBinary returns the path to spec's binary, searching PATH then the +// platform's candidate install locations. It returns a wrapped +// ports.ErrAgentBinaryNotFound when nothing matches, so callers surface a clear +// "command not found" rather than launching an empty argv. ctx cancellation is +// honored between probes. +func ResolveBinary(ctx context.Context, spec BinarySpec) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + names := spec.Names + var candidates []string + if runtime.GOOS == "windows" { + names = spec.WinNames + home, _ := os.UserHomeDir() + appData := os.Getenv("APPDATA") + localAppData := os.Getenv("LOCALAPPDATA") + for _, wp := range spec.WinPaths { + var base string + switch wp.Base { + case WinAppData: + base = appData + case WinLocalAppData: + base = localAppData + case WinHome: + base = home + } + if base == "" { + continue + } + candidates = append(candidates, filepath.Join(append([]string{base}, wp.Parts...)...)) + } + } else { + candidates = append(candidates, spec.UnixPaths...) + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, joinAll(home, spec.UnixHomePaths)...) + } + } + + for _, name := range names { + if err := ctx.Err(); err != nil { + return "", err + } + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + } + + for _, candidate := range candidates { + if err := ctx.Err(); err != nil { + return "", err + } + if hookutil.FileExists(candidate) { + return candidate, nil + } + } + + return "", fmt.Errorf("%s: %w", spec.Label, ports.ErrAgentBinaryNotFound) +} + +// joinAll joins each component slice onto base into an absolute candidate path. +func joinAll(base string, entries [][]string) []string { + out := make([]string, 0, len(entries)) + for _, parts := range entries { + out = append(out, filepath.Join(append([]string{base}, parts...)...)) + } + return out +} diff --git a/backend/internal/adapters/agent/binaryutil/binaryutil_test.go b/backend/internal/adapters/agent/binaryutil/binaryutil_test.go new file mode 100644 index 000000000..7cc3b24c7 --- /dev/null +++ b/backend/internal/adapters/agent/binaryutil/binaryutil_test.go @@ -0,0 +1,81 @@ +package binaryutil + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestResolveBinaryPrefersPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PATH lookup shape differs on windows") + } + dir := t.TempDir() + bin := filepath.Join(dir, "widget") + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + + got, err := ResolveBinary(context.Background(), BinarySpec{Label: "widget", Names: []string{"widget"}}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got != bin { + t.Fatalf("got %q, want %q", got, bin) + } +} + +func TestResolveBinaryFallsBackToHomeCandidate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix home candidate shape") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", t.TempDir()) // empty of the binary + bin := filepath.Join(home, ".local", "bin", "widget") + if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + got, err := ResolveBinary(context.Background(), BinarySpec{ + Label: "widget", + Names: []string{"widget"}, + UnixHomePaths: [][]string{{".local", "bin", "widget"}}, + }) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got != bin { + t.Fatalf("got %q, want %q", got, bin) + } +} + +func TestResolveBinaryNotFound(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := ResolveBinary(context.Background(), BinarySpec{ + Label: "widget", + Names: []string{"widget-does-not-exist"}, + WinNames: []string{"widget-does-not-exist.exe"}, + }) + if !errors.Is(err, ports.ErrAgentBinaryNotFound) { + t.Fatalf("want ErrAgentBinaryNotFound, got %v", err) + } +} + +func TestResolveBinaryHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := ResolveBinary(ctx, BinarySpec{Label: "widget", Names: []string{"widget"}}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("want context.Canceled, got %v", err) + } +} diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index 57b248c6a..a803f86aa 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -17,19 +17,22 @@ package claudecode import ( + "bytes" "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" - "runtime" "strings" "sync" + "time" "github.com/google/uuid" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -49,6 +52,7 @@ var claudeSessionNamespace = uuid.MustParse("a1f0c3d2-7b54-4e96-8a2b-0d9e1f2a3b4 // Plugin is the Claude Code agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -60,6 +64,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 { @@ -150,6 +155,7 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( permissions = cfg.Config.Permissions } appendPermissionFlags(&cmd, permissions) + appendToolFlags(&cmd, cfg.AllowedTools, cfg.DisallowedTools) if model := strings.TrimSpace(cfg.Config.Model); model != "" { cmd = append(cmd, "--model", model) @@ -173,15 +179,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Claude Code receives its prompt in the -// launch command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // PreLaunch is an optional capability the spawn engine invokes (via type // assertion) immediately before creating the session. Claude Code shows a // blocking "do you trust this folder?" dialog the first time it runs in any @@ -257,15 +254,112 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, 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 info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil + if status, ok, err := claudeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil } - return info, true, 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 @@ -301,7 +395,7 @@ func resolveSystemPrompt(cfg ports.LaunchConfig) (string, error) { // // Empty/unrecognized normalizes to default, so no flag is emitted. func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's settings.json defaultMode. case ports.PermissionModeAcceptEdits: @@ -313,74 +407,38 @@ func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) { } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - // Empty or unrecognized: defer to settings.json (no flag). - return ports.PermissionModeDefault +// appendToolFlags emits --allowedTools / --disallowedTools for a tool-scoped +// launch. Each list is joined with commas into one value so rules that contain +// spaces (e.g. "Bash(git diff:*)") are not split into separate tool names. +// Empty lists emit nothing, so an unrestricted launch is unchanged. These rules +// only bite when the launch is off bypassPermissions, which ignores them. +func appendToolFlags(cmd *[]string, allowed, disallowed []string) { + if len(allowed) > 0 { + *cmd = append(*cmd, "--allowedTools", strings.Join(allowed, ",")) + } + if len(disallowed) > 0 { + *cmd = append(*cmd, "--disallowedTools", strings.Join(disallowed, ",")) } } -// ResolveClaudeBinary finds the `claude` binary, searching PATH then a few -// well-known install locations (the native installer's ~/.local/bin, npm -// global, Homebrew). Returns "claude" as a last resort so callers get a -// clear "command not found" rather than an empty argv. +// claudeBinarySpec locates the claude binary: PATH first, then the native +// installer's locations, npm global, Homebrew, and the claude-managed dir. +var claudeBinarySpec = binaryutil.BinarySpec{ + Label: "claude", + Names: []string{"claude"}, + WinNames: []string{"claude.cmd", "claude.exe", "claude"}, + UnixPaths: []string{"/usr/local/bin/claude", "/opt/homebrew/bin/claude"}, + UnixHomePaths: [][]string{{".local", "bin", "claude"}, {".npm", "bin", "claude"}, {".claude", "local", "claude"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.exe"}}, + }, +} + +// ResolveClaudeBinary returns the path to the claude binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveClaudeBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"claude.cmd", "claude.exe", "claude"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "claude.cmd"), - filepath.Join(appData, "npm", "claude.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - } - return "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("claude"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/claude", - "/opt/homebrew/bin/claude", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "claude"), - filepath.Join(home, ".npm", "bin", "claude"), - filepath.Join(home, ".claude", "local", "claude"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, claudeBinarySpec) } func (p *Plugin) claudeBinary(ctx context.Context) (string, error) { @@ -487,8 +545,3 @@ func ensureWorkspaceTrusted(configPath, workspacePath string) error { } return nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index bec96a677..e0f21d1eb 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -183,8 +184,8 @@ func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) { t.Fatal(err) } var config struct { - Hooks map[string][]claudeMatcherGroup `json:"hooks"` - Permissions json.RawMessage `json:"permissions"` + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` + Permissions json.RawMessage `json:"permissions"` } if err := json.Unmarshal(data, &config); err != nil { t.Fatal(err) @@ -260,8 +261,8 @@ func TestUninstallHooksRemovesClaudeHooks(t *testing.T) { t.Fatal(err) } var config struct { - Hooks map[string][]claudeMatcherGroup `json:"hooks"` - Permissions json.RawMessage `json:"permissions"` + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` + Permissions json.RawMessage `json:"permissions"` } if err := json.Unmarshal(data, &config); err != nil { t.Fatal(err) @@ -343,7 +344,7 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { // countClaudeHookCommand counts how many hook entries under one event register // the given command — used to prove no duplicate AO hooks. -func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int { +func countClaudeHookCommand(groups []hooksjson.MatcherGroup, command string) int { count := 0 for _, group := range groups { for _, hook := range group.Hooks { @@ -357,7 +358,7 @@ func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int { // matcherForCommand returns the matcher on the group that registers the given // command (nil if the group has no matcher). -func matcherForCommand(groups []claudeMatcherGroup, command string) *string { +func matcherForCommand(groups []hooksjson.MatcherGroup, command string) *string { for _, group := range groups { for _, hook := range group.Hooks { if hook.Command == command { @@ -490,6 +491,97 @@ func TestManifestID(t *testing.T) { } } +func TestClaudeConfigAuthStatusAuthorizedWithOAuthSubscription(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{ + "hasAvailableSubscription": true, + "oauthAccount": { + "accountUuid": "account-1", + "subscriptionCreatedAt": "2026-01-01T00:00:00Z" + } + }` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusAuthorizedWithOAuthAccount(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{"oauthAccount":{"accountUuid":"account-1"}}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusAuthorizedWithUserID(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + if err := os.WriteFile(path, []byte(`{"userID":"user-1"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusUnknownWithoutOAuthIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{"oauthAccount":{}}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func TestClaudeAuthStatusFromOutputAuthorizedWithCleanJSON(t *testing.T) { + status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":true,"authMethod":"oauth_token"}`)) + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeAuthStatusFromOutputAuthorizedWithPrefixedWarning(t *testing.T) { + output := []byte("warning: ignored config line\n{\"loggedIn\":true,\"authMethod\":\"oauth_token\"}\n") + status, ok := claudeAuthStatusFromOutput(output) + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeAuthStatusFromOutputUnauthorized(t *testing.T) { + status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":false}`)) + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + func TestEnsureWorkspaceTrustedCreatesEntry(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, ".claude.json") @@ -580,6 +672,38 @@ func readJSON(t *testing.T, path string) map[string]any { return m } +func TestGetLaunchCommandEmitsToolAllowlist(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + AllowedTools: []string{"Read", "Grep", "Bash(git diff:*)"}, + DisallowedTools: []string{"Edit", "Write", "Bash(git push:*)"}, + }) + if err != nil { + t.Fatal(err) + } + + // Each list is one comma-joined value so a rule with spaces stays intact. + if !containsSubsequence(cmd, []string{"--allowedTools", "Read,Grep,Bash(git diff:*)"}) { + t.Fatalf("missing joined --allowedTools value; got %#v", cmd) + } + if !containsSubsequence(cmd, []string{"--disallowedTools", "Edit,Write,Bash(git push:*)"}) { + t.Fatalf("missing joined --disallowedTools value; got %#v", cmd) + } +} + +func TestGetLaunchCommandOmitsToolFlagsWhenUnset(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Prompt: "do it"}) + if err != nil { + t.Fatal(err) + } + if contains(cmd, "--allowedTools") || contains(cmd, "--disallowedTools") { + t.Fatalf("unrestricted launch should emit no tool flags; got %#v", cmd) + } +} + func contains(values []string, needle string) bool { for _, v := range values { if v == needle { diff --git a/backend/internal/adapters/agent/claudecode/hooks.go b/backend/internal/adapters/agent/claudecode/hooks.go index da8fbb6a6..88019cdb3 100644 --- a/backend/internal/adapters/agent/claudecode/hooks.go +++ b/backend/internal/adapters/agent/claudecode/hooks.go @@ -2,64 +2,25 @@ package claudecode import ( "context" - "encoding/json" - "errors" - "fmt" - "os" "path/filepath" - "strings" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( - claudeSettingsDirName = ".claude" - claudeSettingsFileName = "settings.local.json" - - // claudeHookCommandPrefix identifies the hook commands AO owns. Every - // managed command starts with it, so install can skip duplicates and - // uninstall can recognize AO entries by prefix without an embedded - // template to diff against. + claudeSettingsDirName = ".claude" + claudeSettingsFileName = "settings.local.json" claudeHookCommandPrefix = "ao hooks claude-code " claudeHookTimeout = 30 ) -type claudeMatcherGroup struct { - // Matcher is a pointer so it round-trips exactly: SessionStart requires a - // real matcher ("startup"); UserPromptSubmit/Stop omit it (Claude ignores - // matcher for those events). omitempty drops a nil matcher on write. - Matcher *string `json:"matcher,omitempty"` - Hooks []claudeHookEntry `json:"hooks"` -} - -type claudeHookEntry struct { - Type string `json:"type"` - Command string `json:"command"` - Timeout int `json:"timeout,omitempty"` -} - -// claudeHookSpec describes one hook AO installs, defined in code rather -// than read from an embedded settings file. -type claudeHookSpec struct { - Event string - Matcher *string - Command string -} - // claudeStartupMatcher is referenced by pointer so SessionStart serializes with // its required "startup" matcher. var claudeStartupMatcher = "startup" -// claudeManagedHooks is the source of truth for the hooks AO installs: -// SessionStart (under the "startup" matcher), UserPromptSubmit, Stop, -// Notification, and SessionEnd. They report normalized session metadata and -// activity-state signals back into AO's store (see DeriveActivityState). -// Notification and SessionEnd carry no matcher: each installs once and fires -// for every sub-type, and the handler filters on the payload's -// notification_type / reason field — installing one command under multiple -// matchers would trip the per-command dedup in claudeHookCommandExists. -var claudeManagedHooks = []claudeHookSpec{ +// claudeManagedHooks is the source of truth for the hooks AO installs. +var claudeManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"}, {Event: "Stop", Command: claudeHookCommandPrefix + "stop"}, @@ -67,282 +28,31 @@ var claudeManagedHooks = []claudeHookSpec{ {Event: "SessionEnd", Command: claudeHookCommandPrefix + "session-end"}, } -// GetAgentHooks installs AO's Claude Code hooks into the worktree-local -// .claude/settings.local.json file (the per-session local settings, not the -// shared .claude/settings.json). The hooks (SessionStart, UserPromptSubmit, -// Stop, Notification, SessionEnd) report normalized session metadata and -// activity-state signals back into AO's store. Existing hooks and unrelated -// settings are preserved, and duplicate AO commands are not appended, so -// the install is idempotent. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(cfg.WorkspacePath) == "" { - return errors.New("claude-code.GetAgentHooks: WorkspacePath is required") - } - - settingsPath := claudeSettingsPath(cfg.WorkspacePath) - topLevel, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - - for event, specs := range groupClaudeHooksByEvent() { - var existingGroups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &existingGroups); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - for _, spec := range specs { - if !claudeHookCommandExists(existingGroups, spec.Command) { - entry := claudeHookEntry{Type: "command", Command: spec.Command, Timeout: claudeHookTimeout} - existingGroups = addClaudeHook(existingGroups, entry, spec.Matcher) - } - } - if err := marshalClaudeHookType(rawHooks, event, existingGroups); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - } - - if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(settingsPath), claudeSettingsFileName); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: gitignore: %w", err) - } - return nil -} - -// UninstallHooks removes AO's Claude Code hooks from the workspace-local -// .claude/settings.local.json file, leaving user-defined hooks and unrelated -// settings untouched. A missing settings file is a no-op. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(workspacePath) == "" { - return errors.New("claude-code.UninstallHooks: workspacePath is required") - } - - settingsPath := claudeSettingsPath(workspacePath) - if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { - return nil - } - topLevel, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - - for _, event := range claudeManagedEvents() { - var groups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &groups); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - groups = removeClaudeManagedHooks(groups) - if err := marshalClaudeHookType(rawHooks, event, groups); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - } - - if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - return nil -} - -// AreHooksInstalled reports whether any AO Claude Code hook is present in -// the workspace-local settings file. A missing file means none are installed. -func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - if strings.TrimSpace(workspacePath) == "" { - return false, errors.New("claude-code.AreHooksInstalled: workspacePath is required") - } - - settingsPath := claudeSettingsPath(workspacePath) - if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { - return false, nil - } - _, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err) - } - - for _, event := range claudeManagedEvents() { - var groups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &groups); err != nil { - return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err) - } - for _, group := range groups { - for _, hook := range group.Hooks { - if isClaudeManagedHook(hook.Command) { - return true, nil - } - } - } - } - return false, nil +// claudeHooks manages AO's hooks in the workspace-local +// .claude/settings.local.json file. +var claudeHooks = hooksjson.Manager{ + Label: "claude-code", + CommandPrefix: claudeHookCommandPrefix, + Timeout: claudeHookTimeout, + Path: claudeSettingsPath, + Managed: claudeManagedHooks, } func claudeSettingsPath(workspacePath string) string { return filepath.Join(workspacePath, claudeSettingsDirName, claudeSettingsFileName) } -// readClaudeSettings loads the settings file into a top-level raw map plus the -// decoded "hooks" sub-map, preserving every key AO doesn't manage. A -// missing or empty file yields empty maps. -func readClaudeSettings(settingsPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { - topLevel = map[string]json.RawMessage{} - rawHooks = map[string]json.RawMessage{} - - data, err := os.ReadFile(settingsPath) //nolint:gosec // path built from caller-owned workspace dir - if errors.Is(err, os.ErrNotExist) { - return topLevel, rawHooks, nil - } - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", settingsPath, err) - } - if strings.TrimSpace(string(data)) == "" { - return topLevel, rawHooks, nil - } - if err := json.Unmarshal(data, &topLevel); err != nil { - return nil, nil, fmt.Errorf("parse %s: %w", settingsPath, err) - } - if hooksRaw, ok := topLevel["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return nil, nil, fmt.Errorf("parse hooks in %s: %w", settingsPath, err) - } - } - return topLevel, rawHooks, nil +// GetAgentHooks installs AO's Claude Code hooks, preserving user-defined hooks and unrelated settings. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return claudeHooks.Install(ctx, cfg.WorkspacePath) } -// writeClaudeSettings folds rawHooks back into topLevel and writes the file. An -// empty hooks map drops the "hooks" key entirely. -func writeClaudeSettings(settingsPath string, topLevel, rawHooks map[string]json.RawMessage) error { - if len(rawHooks) == 0 { - delete(topLevel, "hooks") - } else { - hooksJSON, err := json.Marshal(rawHooks) - if err != nil { - return fmt.Errorf("encode hooks: %w", err) - } - topLevel["hooks"] = hooksJSON - } - - if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { - return fmt.Errorf("create settings dir: %w", err) - } - data, err := json.MarshalIndent(topLevel, "", " ") - if err != nil { - return fmt.Errorf("encode %s: %w", settingsPath, err) - } - data = append(data, '\n') - if err := hookutil.AtomicWriteFile(settingsPath, data, 0o600); err != nil { - return fmt.Errorf("write %s: %w", settingsPath, err) - } - return nil +// UninstallHooks removes AO's Claude Code hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return claudeHooks.Uninstall(ctx, workspacePath) } -// groupClaudeHooksByEvent groups the managed hook specs by their Claude event so -// each event's settings array is rewritten once. -func groupClaudeHooksByEvent() map[string][]claudeHookSpec { - byEvent := map[string][]claudeHookSpec{} - for _, spec := range claudeManagedHooks { - byEvent[spec.Event] = append(byEvent[spec.Event], spec) - } - return byEvent -} - -// claudeManagedEvents returns the distinct Claude events AO manages, in -// the order they first appear in claudeManagedHooks. -func claudeManagedEvents() []string { - seen := map[string]bool{} - events := make([]string, 0, len(claudeManagedHooks)) - for _, spec := range claudeManagedHooks { - if !seen[spec.Event] { - seen[spec.Event] = true - events = append(events, spec.Event) - } - } - return events -} - -func isClaudeManagedHook(command string) bool { - return strings.HasPrefix(command, claudeHookCommandPrefix) -} - -// removeClaudeManagedHooks strips AO hook entries from every group, -// dropping any group left without hooks so the event array doesn't accumulate -// empty matcher objects. -func removeClaudeManagedHooks(groups []claudeMatcherGroup) []claudeMatcherGroup { - result := make([]claudeMatcherGroup, 0, len(groups)) - for _, group := range groups { - kept := make([]claudeHookEntry, 0, len(group.Hooks)) - for _, hook := range group.Hooks { - if !isClaudeManagedHook(hook.Command) { - kept = append(kept, hook) - } - } - if len(kept) > 0 { - group.Hooks = kept - result = append(result, group) - } - } - return result -} - -func parseClaudeHookType(rawHooks map[string]json.RawMessage, event string, target *[]claudeMatcherGroup) error { - data, ok := rawHooks[event] - if !ok { - return nil - } - if err := json.Unmarshal(data, target); err != nil { - return fmt.Errorf("parse %s hooks: %w", event, err) - } - return nil -} - -func marshalClaudeHookType(rawHooks map[string]json.RawMessage, event string, groups []claudeMatcherGroup) error { - if len(groups) == 0 { - delete(rawHooks, event) - return nil - } - data, err := json.Marshal(groups) - if err != nil { - return fmt.Errorf("encode %s hooks: %w", event, err) - } - rawHooks[event] = data - return nil -} - -func claudeHookCommandExists(groups []claudeMatcherGroup, command string) bool { - for _, group := range groups { - for _, hook := range group.Hooks { - if hook.Command == command { - return true - } - } - } - return false -} - -// addClaudeHook appends hook to an existing group with the same matcher (so a -// SessionStart hook lands under its "startup" matcher), creating that group if -// none matches. -func addClaudeHook(groups []claudeMatcherGroup, hook claudeHookEntry, matcher *string) []claudeMatcherGroup { - for i, group := range groups { - if matchersEqual(group.Matcher, matcher) { - groups[i].Hooks = append(groups[i].Hooks, hook) - return groups - } - } - return append(groups, claudeMatcherGroup{Matcher: matcher, Hooks: []claudeHookEntry{hook}}) -} - -func matchersEqual(a, b *string) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return *a == *b +// AreHooksInstalled reports whether any AO Claude Code hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return claudeHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/claudecode/install.go b/backend/internal/adapters/agent/claudecode/install.go new file mode 100644 index 000000000..a809f0c02 --- /dev/null +++ b/backend/internal/adapters/agent/claudecode/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/cline/activity.go b/backend/internal/adapters/agent/cline/activity.go deleted file mode 100644 index 5d51238f0..000000000 --- a/backend/internal/adapters/agent/cline/activity.go +++ /dev/null @@ -1,32 +0,0 @@ -package cline - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Cline hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed by clineManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), not -// the native Cline event name. Cline currently exposes no stable -// session/process-end hook the adapter installs, so runtime exit still falls -// back to the lifecycle reaper. -// -// TODO(cline): ActivityExited is still runtime-observation-owned. If Cline adds -// a stable native session/process-end hook (e.g. session_shutdown via the CLI -// `cline hook` path), map it to ActivityExited here. Until then, ensure the -// reaper can still mark a dead Cline runtime as exited even when the last hook -// signal was sticky waiting_input. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/cline/auth.go b/backend/internal/adapters/agent/cline/auth.go new file mode 100644 index 000000000..850d86e65 --- /dev/null +++ b/backend/internal/adapters/agent/cline/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/cline/auth_test.go b/backend/internal/adapters/agent/cline/auth_test.go new file mode 100644 index 000000000..4bfbed8f9 --- /dev/null +++ b/backend/internal/adapters/agent/cline/auth_test.go @@ -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) +} diff --git a/backend/internal/adapters/agent/cline/cline.go b/backend/internal/adapters/agent/cline/cline.go index 512beeb65..f2fbde636 100644 --- a/backend/internal/adapters/agent/cline/cline.go +++ b/backend/internal/adapters/agent/cline/cline.go @@ -3,9 +3,11 @@ // workspace-local Cline hooks, and reading hook-derived session info. // // Cline is an autonomous coding agent that runs in the terminal (binary -// "cline", installed via `npm i -g cline`). AO drives it headlessly by passing -// the prompt as a positional argument and requesting NDJSON output with -// `--json`, which Cline emits one event per line for machine parsing. +// "cline", installed via `npm i -g cline`). AO drives task launches headlessly +// by passing the prompt as a positional argument and requesting NDJSON output +// with `--json`, which Cline emits one event per line for machine parsing. +// Promptless launches (notably orchestrators) must stay in Cline's interactive +// mode because Cline rejects `--json` without a prompt or piped stdin. // // AO-managed sessions derive native session identity from Cline hooks // (the workspace-local `.clinerules/hooks/` executable scripts AO installs) @@ -14,26 +16,19 @@ package cline import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - clineTitleMetadataKey = "title" - clineSummaryMetadataKey = "summary" -) - // Plugin is the Cline agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -59,26 +54,21 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Cline exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - -// GetLaunchCommand builds the argv to start a new headless Cline session, -// requesting machine-readable NDJSON output (`--json`), applying the approval -// flags, an optional system-prompt override (`-s`), and the initial prompt as -// the trailing positional argument. The prompt is placed after `--` so a -// leading "-" is not read as a flag. +// GetLaunchCommand builds the argv to start a new Cline session. Prompted +// launches request machine-readable NDJSON output (`--json`). Promptless +// launches stay interactive because Cline's JSON output mode requires a prompt +// argument or piped stdin. The prompt is placed after `--` so a leading "-" is +// not read as a flag. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.clineBinary(ctx) if err != nil { return nil, err } - cmd = []string{binary, "--json"} + cmd = []string{binary} + if cfg.Prompt != "" { + cmd = append(cmd, "--json") + } appendApprovalFlags(&cmd, cfg.Permissions) if cfg.SystemPrompt != "" { @@ -92,19 +82,11 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Cline receives its prompt in the -// launch command itself (as a positional argument). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Cline session: -// `cline --json [approval flags] --id `. ok is false when the -// hook-derived native session id has not landed yet, so callers can fall back -// to fresh launch behavior. +// `cline [approval flags] --id `. Resumes are interactive +// because no prompt is supplied here. ok is false when the hook-derived native +// session id has not landed yet, so callers can fall back to fresh launch +// behavior. func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { if err := ctx.Err(); err != nil { return nil, false, err @@ -120,7 +102,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) } cmd = make([]string, 0, 8) - cmd = append(cmd, binary, "--json") + cmd = append(cmd, binary) appendApprovalFlags(&cmd, cfg.Permissions) cmd = append(cmd, "--id", agentSessionID) return cmd, true, nil @@ -132,82 +114,27 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[clineTitleMetadataKey], - Summary: session.Metadata[clineSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil +} + +var clineBinarySpec = binaryutil.BinarySpec{ + Label: "cline", + Names: []string{"cline"}, + WinNames: []string{"cline.cmd", "cline.exe", "cline"}, + UnixPaths: []string{"/usr/local/bin/cline", "/opt/homebrew/bin/cline"}, + UnixHomePaths: [][]string{{".npm-global", "bin", "cline"}, {".npm", "bin", "cline"}, {".local", "bin", "cline"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cline.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cline.exe"}}, + }, } // ResolveClineBinary returns the path to the cline binary on this machine, -// searching PATH then a handful of well-known install locations -// (Homebrew, npm global). Returns "cline" as a last-ditch fallback so callers -// see a clear "command not found" rather than an empty argv. +// searching PATH then a handful of well-known install locations (Homebrew, npm +// global). It returns a wrapped ports.ErrAgentBinaryNotFound when cline is absent. func ResolveClineBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"cline.cmd", "cline.exe", "cline"} { - path, err := exec.LookPath(name) - if err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "cline.cmd"), - filepath.Join(appData, "npm", "cline.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("cline: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("cline"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/cline", - "/opt/homebrew/bin/cline", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".npm-global", "bin", "cline"), - filepath.Join(home, ".npm", "bin", "cline"), - filepath.Join(home, ".local", "bin", "cline"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("cline: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, clineBinarySpec) } func (p *Plugin) clineBinary(ctx context.Context) (string, error) { @@ -227,7 +154,7 @@ func (p *Plugin) clineBinary(ctx context.Context) (string, error) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Cline config/default behavior. case ports.PermissionModeAcceptEdits: @@ -243,20 +170,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--yolo") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/cline/cline_test.go b/backend/internal/adapters/agent/cline/cline_test.go index 7b33121f4..ec1e359e9 100644 --- a/backend/internal/adapters/agent/cline/cline_test.go +++ b/backend/internal/adapters/agent/cline/cline_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -36,6 +36,30 @@ func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) { } } +func TestGetLaunchCommandOmitsJSONForPromptlessInteractiveLaunch(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + SystemPrompt: "coordinate the project", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "cline", + "--yolo", + "-s", "coordinate the project", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } + if contains(cmd, "--json") { + t.Fatalf("promptless Cline launch must not use --json: %#v", cmd) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string @@ -90,7 +114,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cline"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -102,7 +126,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cline"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -221,11 +245,11 @@ func TestUninstallHooksRemovesClineHooks(t *testing.T) { } for _, spec := range clineManagedHooks { - if fileExists(filepath.Join(hooksDir, spec.Event)) { + if hookutil.FileExists(filepath.Join(hooksDir, spec.Event)) { t.Fatalf("%s still present after uninstall", spec.Event) } } - if !fileExists(userHook) { + if !hookutil.FileExists(userHook) { t.Fatal("user PostToolUse hook was removed by uninstall") } } @@ -254,7 +278,6 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { } want := []string{ "cline", - "--json", "--auto-approve", "true", "--id", "session-123", } @@ -301,8 +324,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "session-123", - clineTitleMetadataKey: "Fix login redirect", - clineSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -344,29 +367,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { } } -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - event string - want domain.ActivityState - wantOK bool - }{ - {"session-start", domain.ActivityActive, true}, - {"user-prompt-submit", domain.ActivityActive, true}, - {"stop", domain.ActivityIdle, true}, - {"permission-request", domain.ActivityWaitingInput, true}, - {"unknown", "", false}, - {"", "", false}, - } - for _, tt := range tests { - t.Run(tt.event, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, nil) - if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, got, ok, tt.want, tt.wantOK) - } - }) - } -} - func TestContextCancellationIsHonored(t *testing.T) { plugin := &Plugin{resolvedBinary: "cline"} ctx, cancel := context.WithCancel(context.Background()) diff --git a/backend/internal/adapters/agent/cline/hooks.go b/backend/internal/adapters/agent/cline/hooks.go index 53df0d0b7..184d4fe8c 100644 --- a/backend/internal/adapters/agent/cline/hooks.go +++ b/backend/internal/adapters/agent/cline/hooks.go @@ -83,11 +83,11 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi for _, spec := range clineManagedHooks { scriptPath := filepath.Join(hooksDir, spec.Event) // Never clobber a user-authored hook with the same event name. - if fileExists(scriptPath) && !isManagedClineHook(scriptPath) { + if hookutil.FileExists(scriptPath) && !isManagedClineHook(scriptPath) { continue } script := renderClineHookScript(spec.Subcommand) - if err := atomicWriteFile(scriptPath, []byte(script), 0o700); err != nil { + if err := hookutil.AtomicWriteFile(scriptPath, []byte(script), 0o700); err != nil { return fmt.Errorf("cline.GetAgentHooks: write %s: %w", spec.Event, err) } written = append(written, spec.Event) @@ -116,7 +116,7 @@ func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error for _, spec := range clineManagedHooks { scriptPath := filepath.Join(hooksDir, spec.Event) - if !fileExists(scriptPath) || !isManagedClineHook(scriptPath) { + if !hookutil.FileExists(scriptPath) || !isManagedClineHook(scriptPath) { continue } if err := os.Remove(scriptPath); err != nil && !errors.Is(err, os.ErrNotExist) { @@ -143,7 +143,7 @@ func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (b for _, spec := range clineManagedHooks { scriptPath := filepath.Join(hooksDir, spec.Event) - if fileExists(scriptPath) && isManagedClineHook(scriptPath) { + if hookutil.FileExists(scriptPath) && isManagedClineHook(scriptPath) { return true, nil } } @@ -177,26 +177,3 @@ func isManagedClineHook(scriptPath string) bool { } return strings.Contains(string(data), clineHookMarker) } - -// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- -// write can't leave a truncated script that Cline then fails to execute. -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} diff --git a/backend/internal/adapters/agent/cline/install.go b/backend/internal/adapters/agent/cline/install.go new file mode 100644 index 000000000..d3dd2267a --- /dev/null +++ b/backend/internal/adapters/agent/cline/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/codex/codex.go b/backend/internal/adapters/agent/codex/codex.go index 43d1f6b04..8985c0ec6 100644 --- a/backend/internal/adapters/agent/codex/codex.go +++ b/backend/internal/adapters/agent/codex/codex.go @@ -13,16 +13,20 @@ 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/adapters/agent/agentbase" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) // Plugin is the Codex agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -34,6 +38,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 { @@ -48,14 +53,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Codex exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new Codex session, applying the // no-update-check, hook-trust bypass, and approval flags, AO's session-flag // activity hooks, the workspace trust override, optional system-prompt @@ -89,15 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Codex receives its prompt in the -// launch command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Codex // session: `codex resume `. ok is false when the hook-derived // native session id has not landed yet, so callers can fall back to fresh @@ -135,21 +123,41 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, 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 } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil + 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() } - return info, true, nil + text := strings.ToLower(string(out)) + if strings.Contains(text, "not logged in") || strings.Contains(text, "logged out") { + return ports.AgentAuthStatusUnauthorized, nil + } + if strings.Contains(text, "logged in") { + return ports.AgentAuthStatusAuthorized, nil + } + if err != nil { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil } // ResolveCodexBinary returns the path to the codex binary on this machine, // searching PATH then a handful of well-known install locations -// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback -// so callers see a clear "command not found" rather than an empty argv. +// (Homebrew, Cargo, npm global, NVM). Returns "codex" as a last-ditch +// fallback so callers see a clear "command not found" rather than an empty +// argv. func ResolveCodexBinary(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -203,6 +211,7 @@ func ResolveCodexBinary(ctx context.Context) (string, error) { filepath.Join(home, ".cargo", "bin", "codex"), filepath.Join(home, ".npm", "bin", "codex"), ) + candidates = append(candidates, nvmNodeBinCandidates(home, "codex")...) } for _, candidate := range candidates { @@ -217,6 +226,14 @@ func ResolveCodexBinary(ctx context.Context) (string, error) { return "", fmt.Errorf("codex: %w", ports.ErrAgentBinaryNotFound) } +func nvmNodeBinCandidates(home, binary string) []string { + matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin", binary)) + if err != nil || len(matches) == 0 { + return nil + } + sort.Sort(sort.Reverse(sort.StringSlice(matches))) + return matches +} func resolveNativeWindowsCodex(path string) string { if runtime.GOOS != "windows" || !strings.EqualFold(filepath.Ext(path), ".cmd") { return path @@ -301,7 +318,7 @@ func appendTerminalCompatibilityFlags(cmd *[]string) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // Codex sessions are AO-managed and run headlessly inside a terminal // mux pane; default to no approval prompts unless project settings @@ -316,19 +333,8 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { +// fileExists is a package var so tests can stub it to scope candidate probing. +var fileExists = func(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() } diff --git a/backend/internal/adapters/agent/codex/codex_test.go b/backend/internal/adapters/agent/codex/codex_test.go index 0981b2512..49b60a538 100644 --- a/backend/internal/adapters/agent/codex/codex_test.go +++ b/backend/internal/adapters/agent/codex/codex_test.go @@ -90,6 +90,38 @@ func TestGetLaunchCommandWithoutWorkspaceOmitsTrustFlag(t *testing.T) { } } +func TestResolveCodexBinaryFindsNVMInstallWhenPathIsSparse(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("NVM install discovery is Unix-specific") + } + home := t.TempDir() + binDir := filepath.Join(home, ".nvm", "versions", "node", "v20.19.4", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + want := filepath.Join(binDir, "codex") + if err := os.WriteFile(want, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("PATH", "") + origFileExists := fileExists + fileExists = func(path string) bool { + return strings.HasPrefix(path, home+string(os.PathSeparator)) && origFileExists(path) + } + t.Cleanup(func() { + fileExists = origFileExists + }) + + got, err := ResolveCodexBinary(context.Background()) + if err != nil { + t.Fatalf("ResolveCodexBinary: %v", err) + } + if got != want { + t.Fatalf("ResolveCodexBinary = %q, want %q", got, want) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string @@ -201,7 +233,7 @@ func TestCodexTOMLBasicStringEscapes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "codex"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -213,7 +245,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "codex"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { diff --git a/backend/internal/adapters/agent/codex/install.go b/backend/internal/adapters/agent/codex/install.go new file mode 100644 index 000000000..94465ab41 --- /dev/null +++ b/backend/internal/adapters/agent/codex/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/continueagent/auth.go b/backend/internal/adapters/agent/continueagent/auth.go new file mode 100644 index 000000000..e80dc538d --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/continueagent/auth_test.go b/backend/internal/adapters/agent/continueagent/auth_test.go new file mode 100644 index 000000000..6dfe5597e --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/auth_test.go @@ -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) + } +} diff --git a/backend/internal/adapters/agent/continueagent/continueagent.go b/backend/internal/adapters/agent/continueagent/continueagent.go index 65f4122ea..0d82c3c4e 100644 --- a/backend/internal/adapters/agent/continueagent/continueagent.go +++ b/backend/internal/adapters/agent/continueagent/continueagent.go @@ -22,15 +22,12 @@ package continueagent import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -39,9 +36,22 @@ import ( // (NOT the Go package name "continueagent"). const adapterID = "continue" +var continueBinarySpec = binaryutil.BinarySpec{ + Label: "cn", + Names: []string{"cn"}, + WinNames: []string{"cn.cmd", "cn.exe", "cn"}, + UnixPaths: []string{"/usr/local/bin/cn", "/opt/homebrew/bin/cn"}, + UnixHomePaths: [][]string{{".npm-global", "bin", "cn"}, {".local", "bin", "cn"}, {".npm", "bin", "cn"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.exe"}}, + }, +} + // Plugin is the Continue CLI agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -67,14 +77,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds `cn --print [--auto|--readonly] `. // // `--print` runs Continue in non-interactive (headless) mode. The prompt is the @@ -97,14 +99,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetAgentHooks reuses the Claude Code hook installer because the Continue CLI // natively reads Claude Code hook settings. // @@ -154,15 +148,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveContinueBinary finds the `cn` binary (Continue CLI), searching PATH then @@ -170,63 +157,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por // callers get the shell's normal command-not-found behavior if Continue is // absent. func ResolveContinueBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"cn.cmd", "cn.exe", "cn"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "cn.cmd"), - filepath.Join(appData, "npm", "cn.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("cn"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/cn", - "/opt/homebrew/bin/cn", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".npm-global", "bin", "cn"), - filepath.Join(home, ".local", "bin", "cn"), - filepath.Join(home, ".npm", "bin", "cn"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, continueBinarySpec) } func (p *Plugin) continueBinary(ctx context.Context) (string, error) { @@ -251,7 +182,7 @@ func (p *Plugin) continueBinary(ctx context.Context) (string, error) { // and the two flags are mutually exclusive. Default and AcceptEdits emit no flag // so Continue defers to the user's own config / default behavior. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Continue config / default behavior. case ports.PermissionModeAcceptEdits: @@ -262,20 +193,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--auto") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/continueagent/continueagent_test.go b/backend/internal/adapters/agent/continueagent/continueagent_test.go index 1cb146578..b3a23e097 100644 --- a/backend/internal/adapters/agent/continueagent/continueagent_test.go +++ b/backend/internal/adapters/agent/continueagent/continueagent_test.go @@ -29,6 +29,12 @@ func TestManifest(t *testing.T) { } } +func TestDoesNotImplementAuthChecker(t *testing.T) { + if _, ok := any(&Plugin{}).(ports.AgentAuthChecker); ok { + t.Fatal("Continue must not implement AgentAuthChecker; catalog refresh must not run model-call auth probes") + } +} + func TestGetConfigSpecEmpty(t *testing.T) { spec, err := (&Plugin{}).GetConfigSpec(context.Background()) if err != nil { diff --git a/backend/internal/adapters/agent/continueagent/install.go b/backend/internal/adapters/agent/continueagent/install.go new file mode 100644 index 000000000..d9fe23a22 --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/copilot/activity.go b/backend/internal/adapters/agent/copilot/activity.go deleted file mode 100644 index a32110547..000000000 --- a/backend/internal/adapters/agent/copilot/activity.go +++ /dev/null @@ -1,38 +0,0 @@ -package copilot - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Copilot CLI hook event onto an AO activity state. -// The bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in copilotManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), NOT the -// native Copilot event name. Keeping this beside hooks.go means the events AO -// installs and what they mean live in one place. -// -// Copilot CLI documents that prompt-style hooks (userPromptSubmitted) do NOT -// fire in non-interactive `-p` mode, while preToolUse fires before every tool -// invocation (including ones that would prompt the user for approval) and is -// the most reliable signal in CLI pipe mode (-p). AO still installs every event -// so interactive resume and future modes report activity; the -// permission-request → waiting_input mapping (driven by preToolUse) is the one -// that always fires under AO's headless launch. -// -// TODO(copilot): ActivityExited is still runtime-observation-owned. If Copilot's -// sessionEnd/agentStop hook proves reliable in `-p` mode, map a real -// session-end here. Until then, the lifecycle reaper marks a dead Copilot -// runtime exited even when the last hook signal was sticky waiting_input. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/copilot/auth.go b/backend/internal/adapters/agent/copilot/auth.go new file mode 100644 index 000000000..6444bbaad --- /dev/null +++ b/backend/internal/adapters/agent/copilot/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/copilot/copilot.go b/backend/internal/adapters/agent/copilot/copilot.go index 5a5d9ee45..043023e1b 100644 --- a/backend/internal/adapters/agent/copilot/copilot.go +++ b/backend/internal/adapters/agent/copilot/copilot.go @@ -6,9 +6,9 @@ // "copilot", installed via npm "@github/copilot"), NOT the older `gh copilot` // suggest/explain extension. // -// Launch runs the CLI in non-interactive ("programmatic") mode with `-p -// ` so it executes the task and exits. Permission modes map onto the -// CLI's allow flags (`--allow-tool`, `--allow-all-tools`, `--allow-all`). +// Launch runs the CLI in interactive mode so AO can keep a durable terminal +// pane attached to the session. Permission modes map onto the CLI's allow flags +// (`--allow-tool`, `--allow-all-tools`, `--allow-all`). // Restore continues an existing session via `--resume `; the // native session id (a UUID under ~/.copilot/session-state/) is captured by the // SessionStart hook AO installs (see hooks.go). @@ -28,19 +28,17 @@ import ( "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - adapterID = "copilot" - - copilotTitleMetadataKey = "title" - copilotSummaryMetadataKey = "summary" -) +const adapterID = "copilot" // Plugin is the GitHub Copilot CLI agent adapter. It is safe for concurrent use; // the binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -66,21 +64,14 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Copilot exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - 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 ] +// copilot [permission flags] // -// The prompt is delivered with `-p`, which runs the prompt in non-interactive -// mode and exits when done. Copilot CLI does not have a documented -// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored. +// The prompt is delivered after the process starts; using `-p` runs Copilot in +// programmatic mode and exits when done, which leaves AO's terminal pane blank +// or dead. Copilot CLI does not have a documented system-prompt-injection flag, +// so SystemPrompt/SystemPromptFile are ignored. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.copilotBinary(ctx) if err != nil { @@ -90,20 +81,18 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( cmd = []string{binary} appendApprovalFlags(&cmd, cfg.Permissions) - if cfg.Prompt != "" { - cmd = append(cmd, "-p", cfg.Prompt) - } - return cmd, nil } -// GetPromptDeliveryStrategy reports that Copilot receives its prompt in the -// launch command itself (via `-p`). +// GetPromptDeliveryStrategy reports that Copilot receives its prompt after the +// interactive process starts. This overrides the agentbase.Base default +// (in-command) because Copilot's `-p` programmatic mode exits when done, which +// would leave AO's terminal pane dead. func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { if err := ctx.Err(); err != nil { return "", err } - return ports.PromptDeliveryInCommand, nil + return ports.PromptDeliveryAfterStart, nil } // GetRestoreCommand rebuilds the argv that continues an existing Copilot @@ -141,21 +130,16 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[copilotTitleMetadataKey], - Summary: session.Metadata[copilotSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveCopilotBinary returns the path to the copilot binary on this machine, // searching PATH then a handful of well-known install locations (npm global, -// Homebrew). Returns "copilot" as a last-ditch fallback so callers see a clear -// "command not found" rather than an empty argv. +// Homebrew, the VS Code extension's bundled CLI). When the resolved path is the +// npm-loader shim, the platform-native binary is returned instead. This resolver +// stays hand-rolled (rather than binaryutil.ResolveBinary) because of that +// native-loader indirection. func ResolveCopilotBinary(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -182,7 +166,7 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { candidates = append(candidates, filepath.Join(home, ".copilot", "bin", "copilot.exe")) } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -194,6 +178,9 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { } if path, err := exec.LookPath("copilot"); err == nil && path != "" { + if native := copilotNativeBinaryForLoader(path); native != "" { + return native, nil + } return path, nil } @@ -206,11 +193,15 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { filepath.Join(home, ".copilot", "bin", "copilot"), filepath.Join(home, ".npm", "bin", "copilot"), filepath.Join(home, ".local", "bin", "copilot"), + filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage", "github.copilot-chat", "copilotCli", "copilot"), ) } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { + if native := copilotNativeBinaryForLoader(candidate); native != "" { + return native, nil + } return candidate, nil } if err := ctx.Err(); err != nil { @@ -221,6 +212,25 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { return "", fmt.Errorf("copilot: %w", ports.ErrAgentBinaryNotFound) } +func copilotNativeBinaryForLoader(path string) string { + if path == "" || runtime.GOOS == "windows" { + return "" + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil || filepath.Base(resolved) != "npm-loader.js" { + return "" + } + platform := runtime.GOOS + if platform == "darwin" { + platform = "darwin" + } + native := filepath.Join(filepath.Dir(resolved), "node_modules", ".bin", "copilot-"+platform+"-"+runtime.GOARCH) + if hookutil.FileExists(native) { + return native + } + return "" +} + func (p *Plugin) copilotBinary(ctx context.Context) (string, error) { p.binaryMu.Lock() defer p.binaryMu.Unlock() @@ -240,12 +250,12 @@ func (p *Plugin) copilotBinary(ctx context.Context) (string, error) { // appendApprovalFlags maps AO's 4 permission modes onto Copilot CLI approval // flags (https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-programmatic-reference): // -// default → no flag (defer to ~/.copilot config / per-tool prompts) -// accept-edits → --allow-tool 'write' (auto-approve file edits only) -// auto → --allow-all-tools (auto-approve every tool, still scoped paths/urls) -// bypass-permissions → --allow-all (full bypass: tools, paths, urls) +// default -> no flag (defer to ~/.copilot config / per-tool prompts) +// accept-edits -> --allow-tool 'write' (auto-approve file edits only) +// auto -> --allow-all-tools (auto-approve every tool, still scoped paths/urls) +// bypass-permissions -> --allow-all (full bypass: tools, paths, urls) func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's ~/.copilot config / interactive prompts. case ports.PermissionModeAcceptEdits: @@ -256,20 +266,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--allow-all") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go index d4c4c0c1c..711e37209 100644 --- a/backend/internal/adapters/agent/copilot/copilot_test.go +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -6,9 +6,9 @@ import ( "os" "path/filepath" "reflect" + "runtime" "testing" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -33,7 +33,7 @@ func TestGetLaunchCommandBuildsArgv(t *testing.T) { t.Fatal(err) } - want := []string{"copilot", "--allow-all", "-p", "-fix this"} + want := []string{"copilot", "--allow-all"} if !reflect.DeepEqual(cmd, want) { t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) } @@ -117,19 +117,19 @@ func TestGetLaunchCommandRespectsCanceledContext(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "copilot"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { t.Fatal(err) } - if got != ports.PromptDeliveryInCommand { + if got != ports.PromptDeliveryAfterStart { t.Fatalf("unexpected strategy: %q", got) } } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "copilot"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -140,6 +140,115 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { } } +func TestCopilotNativeBinaryForNpmLoader(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("npm loader native binary naming is covered on Unix-like platforms") + } + dir := t.TempDir() + packageDir := filepath.Join(dir, "lib", "node_modules", "@github", "copilot") + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(filepath.Join(packageDir, "node_modules", ".bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + loader := filepath.Join(packageDir, "npm-loader.js") + if err := os.WriteFile(loader, []byte("#!/usr/bin/env node\n"), 0o755); err != nil { + t.Fatal(err) + } + native := filepath.Join(packageDir, "node_modules", ".bin", "copilot-"+runtime.GOOS+"-"+runtime.GOARCH) + if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(binDir, "copilot") + if err := os.Symlink(loader, link); err != nil { + t.Fatal(err) + } + + want, err := filepath.EvalSymlinks(native) + if err != nil { + t.Fatal(err) + } + got, err := filepath.EvalSymlinks(copilotNativeBinaryForLoader(link)) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("native binary = %q, want %q", got, want) + } +} + +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + clearCopilotAuthEnv(t) + t.Setenv("GH_TOKEN", "github_pat_test") + plugin := &Plugin{resolvedBinary: "copilot"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestCopilotConfigAuthStatusAuthorizedWithPlainTextToken(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(`{"authToken":"token"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotConfigAuthStatus(configPath) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestCopilotConfigAuthStatusUnauthorizedWithEmptyConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotConfigAuthStatus(configPath) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestCopilotSessionStateAuthStatusAuthorizedWithModelEvent(t *testing.T) { + dir := t.TempDir() + sessionDir := filepath.Join(dir, "session-1") + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, "events.jsonl"), []byte(`{"type":"tool.execution_complete","data":{"model":"claude-sonnet-4.5"}}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotSessionStateAuthStatus(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func clearCopilotAuthEnv(t *testing.T) { + t.Helper() + for _, name := range copilotTokenEnvVars { + t.Setenv(name, "") + } +} + func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { plugin := &Plugin{resolvedBinary: "copilot"} @@ -199,8 +308,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "uuid-123", - copilotTitleMetadataKey: "Fix login redirect", - copilotSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -397,29 +506,6 @@ func TestCopilotManagedHooksUseDocumentedEventNames(t *testing.T) { } } -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - event string - wantState domain.ActivityState - wantOK bool - }{ - {"session-start", domain.ActivityActive, true}, - {"user-prompt-submit", domain.ActivityActive, true}, - {"stop", domain.ActivityIdle, true}, - {"permission-request", domain.ActivityWaitingInput, true}, - {"unknown", "", false}, - {"", "", false}, - } - for _, tt := range tests { - t.Run(tt.event, func(t *testing.T) { - state, ok := DeriveActivityState(tt.event, nil) - if state != tt.wantState || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, state, ok, tt.wantState, tt.wantOK) - } - }) - } -} - func contains(values []string, needle string) bool { for _, value := range values { if value == needle { diff --git a/backend/internal/adapters/agent/copilot/hooks.go b/backend/internal/adapters/agent/copilot/hooks.go index 65e2e280d..39f02ae61 100644 --- a/backend/internal/adapters/agent/copilot/hooks.go +++ b/backend/internal/adapters/agent/copilot/hooks.go @@ -238,40 +238,12 @@ func writeCopilotHooks(hooksPath string, file copilotHookFile) error { return fmt.Errorf("encode %s: %w", hooksPath, err) } data = append(data, '\n') - if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { + if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { return fmt.Errorf("write %s: %w", hooksPath, err) } return nil } -// atomicWriteFile writes data to path via a temp file in the same directory -// followed by a rename, so a crash or signal mid-write can't leave a truncated -// or empty file that Copilot then fails to parse (silently disabling hooks). -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() // no-op once renamed - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} - // isCopilotManagedHook reports whether an entry is one AO owns, recognized by the // command prefix on either the bash or powershell command. func isCopilotManagedHook(entry copilotHookEntry) bool { diff --git a/backend/internal/adapters/agent/copilot/install.go b/backend/internal/adapters/agent/copilot/install.go new file mode 100644 index 000000000..67b5e10e4 --- /dev/null +++ b/backend/internal/adapters/agent/copilot/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/crush/auth.go b/backend/internal/adapters/agent/crush/auth.go new file mode 100644 index 000000000..15e58ab6e --- /dev/null +++ b/backend/internal/adapters/agent/crush/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/crush/auth_test.go b/backend/internal/adapters/agent/crush/auth_test.go new file mode 100644 index 000000000..f47f5230b --- /dev/null +++ b/backend/internal/adapters/agent/crush/auth_test.go @@ -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 +} diff --git a/backend/internal/adapters/agent/crush/crush.go b/backend/internal/adapters/agent/crush/crush.go index 6f192c8a3..3971e573c 100644 --- a/backend/internal/adapters/agent/crush/crush.go +++ b/backend/internal/adapters/agent/crush/crush.go @@ -8,15 +8,12 @@ package crush import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -29,6 +26,7 @@ const ( // Plugin is the Crush agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -54,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Crush exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start an interactive Crush session. // Shape: // @@ -102,15 +92,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Crush receives its prompt in the -// launch command itself as a positional argument. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Crush session: // `crush [--cwd ] [--yolo] --session `. // It re-applies the permission flag but not the prompt, which the session @@ -143,83 +124,24 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo surfaces Crush session metadata. Currently a no-op since Crush -// doesn't have full hooks support like Claude Code and Codex. Returns false -// to indicate no metadata is available. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - // No-op for now since Crush doesn't have full hooks support - return ports.SessionInfo{}, false, nil +var crushBinarySpec = binaryutil.BinarySpec{ + Label: "crush", + Names: []string{"crush"}, + WinNames: []string{"crush.cmd", "crush.exe", "crush"}, + UnixPaths: []string{"/usr/local/bin/crush", "/opt/homebrew/bin/crush"}, + UnixHomePaths: [][]string{{".local", "bin", "crush"}, {".cargo", "bin", "crush"}, {".npm", "bin", "crush"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "crush.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "crush.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "crush.exe"}}, + }, } // ResolveCrushBinary returns the path to the crush binary on this machine, -// searching PATH then a handful of well-known install locations. -// Returns "crush" as a last-ditch fallback. +// searching PATH then a handful of well-known install locations. It returns a +// wrapped ports.ErrAgentBinaryNotFound when crush is absent. func ResolveCrushBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"crush.cmd", "crush.exe", "crush"} { - path, err := exec.LookPath(name) - if err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "crush.cmd"), - filepath.Join(appData, "npm", "crush.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "crush.exe")) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("crush: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("crush"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/crush", - "/opt/homebrew/bin/crush", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "crush"), - filepath.Join(home, ".cargo", "bin", "crush"), - filepath.Join(home, ".npm", "bin", "crush"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("crush: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, crushBinarySpec) } func (p *Plugin) crushBinary(ctx context.Context) (string, error) { @@ -237,8 +159,3 @@ func (p *Plugin) crushBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/crush/crush_test.go b/backend/internal/adapters/agent/crush/crush_test.go index 45756dc14..b7cb1b26e 100644 --- a/backend/internal/adapters/agent/crush/crush_test.go +++ b/backend/internal/adapters/agent/crush/crush_test.go @@ -90,7 +90,7 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "crush"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { diff --git a/backend/internal/adapters/agent/crush/install.go b/backend/internal/adapters/agent/crush/install.go new file mode 100644 index 000000000..e6d4f041b --- /dev/null +++ b/backend/internal/adapters/agent/crush/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/cursor/activity.go b/backend/internal/adapters/agent/cursor/activity.go deleted file mode 100644 index 068ce985a..000000000 --- a/backend/internal/adapters/agent/cursor/activity.go +++ /dev/null @@ -1,30 +0,0 @@ -package cursor - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Cursor hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in cursorManagedHooks -// ("session-start", "user-prompt-submit", "stop", "permission-request"), not -// the native Cursor event name. Cursor currently has no SessionEnd/Notification -// equivalent in the adapter, so runtime exit still falls back to the reaper. -// -// TODO(cursor): ActivityExited is still runtime-observation-owned. If Cursor -// adds a native session/process-end hook, map that hook to ActivityExited here. -// Until then, make sure the lifecycle reaper can still mark a dead Cursor -// runtime as exited even when the last hook signal was sticky waiting_input. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/cursor/activity_test.go b/backend/internal/adapters/agent/cursor/activity_test.go deleted file mode 100644 index 9b9e13227..000000000 --- a/backend/internal/adapters/agent/cursor/activity_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package cursor - -import ( - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - name string - event string - want domain.ActivityState - wantOK bool - }{ - {"session start -> active", "session-start", domain.ActivityActive, true}, - {"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true}, - {"stop -> idle", "stop", domain.ActivityIdle, true}, - {"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true}, - {"unknown event -> no signal", "frobnicate", "", false}, - {"native event name -> no signal", "beforeShellExecution", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, []byte(`{}`)) - if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", - tt.event, got, ok, tt.want, tt.wantOK) - } - }) - } -} diff --git a/backend/internal/adapters/agent/cursor/auth.go b/backend/internal/adapters/agent/cursor/auth.go new file mode 100644 index 000000000..487d9ad0a --- /dev/null +++ b/backend/internal/adapters/agent/cursor/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/cursor/auth_test.go b/backend/internal/adapters/agent/cursor/auth_test.go new file mode 100644 index 000000000..5e1539e76 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/auth_test.go @@ -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) + } +} diff --git a/backend/internal/adapters/agent/cursor/cursor.go b/backend/internal/adapters/agent/cursor/cursor.go index ecc883556..9b9dc9dfd 100644 --- a/backend/internal/adapters/agent/cursor/cursor.go +++ b/backend/internal/adapters/agent/cursor/cursor.go @@ -18,17 +18,15 @@ import ( "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - cursorTitleMetadataKey = "title" - cursorSummaryMetadataKey = "summary" -) - // Plugin is the Cursor agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -54,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Cursor exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new Cursor CLI session: // // cursor-agent -p --output-format stream-json --trust [permission flags] @@ -92,15 +82,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Cursor receives its prompt in the -// launch command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Cursor CLI // session: // @@ -136,15 +117,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[cursorTitleMetadataKey], - Summary: session.Metadata[cursorSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveCursorBinary returns the path to the cursor-agent binary on this @@ -183,7 +157,7 @@ func ResolveCursorBinary(ctx context.Context) (string, error) { ) for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -211,7 +185,7 @@ func (p *Plugin) cursorBinary(ctx context.Context) (string, error) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Cursor config approvalMode. case ports.PermissionModeAcceptEdits: @@ -223,20 +197,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--yolo") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/cursor/cursor_test.go b/backend/internal/adapters/agent/cursor/cursor_test.go index 6600a9cd4..3e20d24a9 100644 --- a/backend/internal/adapters/agent/cursor/cursor_test.go +++ b/backend/internal/adapters/agent/cursor/cursor_test.go @@ -113,7 +113,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cursor-agent"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -125,7 +125,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cursor-agent"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -200,8 +200,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "chat-123", - cursorTitleMetadataKey: "Fix login redirect", - cursorSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) diff --git a/backend/internal/adapters/agent/cursor/install.go b/backend/internal/adapters/agent/cursor/install.go new file mode 100644 index 000000000..eaf9f3ce0 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/devin/auth.go b/backend/internal/adapters/agent/devin/auth.go new file mode 100644 index 000000000..0a3940b85 --- /dev/null +++ b/backend/internal/adapters/agent/devin/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/devin/auth_test.go b/backend/internal/adapters/agent/devin/auth_test.go new file mode 100644 index 000000000..d33b3abee --- /dev/null +++ b/backend/internal/adapters/agent/devin/auth_test.go @@ -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) + } +} diff --git a/backend/internal/adapters/agent/devin/devin.go b/backend/internal/adapters/agent/devin/devin.go index dade0428f..55097c34a 100644 --- a/backend/internal/adapters/agent/devin/devin.go +++ b/backend/internal/adapters/agent/devin/devin.go @@ -24,26 +24,30 @@ package devin import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - devinTitleMetadataKey = "title" - devinSummaryMetadataKey = "summary" -) +var devinBinarySpec = binaryutil.BinarySpec{ + Label: "devin", + Names: []string{"devin"}, + WinNames: []string{"devin.cmd", "devin.exe", "devin"}, + UnixPaths: []string{"/usr/local/bin/devin", "/opt/homebrew/bin/devin"}, + UnixHomePaths: [][]string{{".devin", "bin", "devin"}, {".local", "bin", "devin"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinHome, Parts: []string{".devin", "bin", "devin.exe"}}, + }, +} // Plugin is the Devin for Terminal agent adapter. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -69,14 +73,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds `devin [--permission-mode ] -p `. // Prompt is delivered via -p (in command, non-interactive print mode). // @@ -99,14 +95,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetAgentHooks reuses the Claude Code hook installer because Devin for Terminal // has a documented Claude Code compatibility layer. // @@ -161,74 +149,13 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[devinTitleMetadataKey], - Summary: session.Metadata[devinSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveDevinBinary finds the `devin` binary (Cognition Devin for Terminal CLI). func ResolveDevinBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"devin.cmd", "devin.exe", "devin"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".devin", "bin", "devin.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("devin: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("devin"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/devin", - "/opt/homebrew/bin/devin", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".devin", "bin", "devin"), - filepath.Join(home, ".local", "bin", "devin"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("devin: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, devinBinarySpec) } func (p *Plugin) devinBinary(ctx context.Context) (string, error) { @@ -251,7 +178,7 @@ func (p *Plugin) devinBinary(ctx context.Context) (string, error) { // permission values (`auto`/normal and `dangerous`/bypass), per // `devin --permission-mode -h`. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to ~/.config/devin/config.json (default mode is auto). case ports.PermissionModeAcceptEdits: @@ -264,20 +191,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--permission-mode", "dangerous") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/devin/devin_test.go b/backend/internal/adapters/agent/devin/devin_test.go index 26159b811..9fe36cf07 100644 --- a/backend/internal/adapters/agent/devin/devin_test.go +++ b/backend/internal/adapters/agent/devin/devin_test.go @@ -195,8 +195,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "devin-ses-1", - devinTitleMetadataKey: "Fix login redirect", - devinSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", }, }) if err != nil { diff --git a/backend/internal/adapters/agent/devin/install.go b/backend/internal/adapters/agent/devin/install.go new file mode 100644 index 000000000..327c6154a --- /dev/null +++ b/backend/internal/adapters/agent/devin/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/droid/auth.go b/backend/internal/adapters/agent/droid/auth.go new file mode 100644 index 000000000..b31caa15a --- /dev/null +++ b/backend/internal/adapters/agent/droid/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/droid/auth_test.go b/backend/internal/adapters/agent/droid/auth_test.go new file mode 100644 index 000000000..56e777393 --- /dev/null +++ b/backend/internal/adapters/agent/droid/auth_test.go @@ -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) + } +} diff --git a/backend/internal/adapters/agent/droid/droid.go b/backend/internal/adapters/agent/droid/droid.go index 7643ab141..a06f0e02f 100644 --- a/backend/internal/adapters/agent/droid/droid.go +++ b/backend/internal/adapters/agent/droid/droid.go @@ -23,28 +23,21 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - // Normalized session-metadata keys the hooks persist into the AO session - // store and SessionInfo reads back. Shared vocabulary with the Codex, Grok, - // and opencode adapters so the dashboard treats every agent uniformly. - droidTitleMetadataKey = "title" - droidSummaryMetadataKey = "summary" -) - // Plugin is the Droid agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -70,14 +63,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new interactive Droid session: // // droid [--settings ] [--append-system-prompt[-file] ] [prompt] @@ -115,15 +100,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Droid receives its prompt in the launch -// command itself (the positional prompt argument). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Droid session: // `droid [--settings ] -r `. It re-applies the permission // autonomy (resume otherwise reverts to the configured default) but not the @@ -161,15 +137,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[droidTitleMetadataKey], - Summary: session.Metadata[droidSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // droidAutonomyLevel maps an AO permission mode onto Droid's @@ -182,7 +151,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por // bypass-permissions → high (max interactive autonomy; Droid's interactive // TUI has no exec-style --skip-permissions-unsafe) func droidAutonomyLevel(mode ports.PermissionMode) string { - switch normalizePermissionMode(mode) { + switch ports.NormalizePermissionMode(mode) { case ports.PermissionModeAcceptEdits: return "low" case ports.PermissionModeAuto: @@ -249,73 +218,24 @@ func sanitizeSessionID(id string) string { return b.String() } -// ResolveDroidBinary finds the `droid` binary (Factory Droid CLI), searching -// PATH then a handful of well-known install locations. Returns "droid" as a -// last-ditch fallback so callers see a clear "command not found" rather than an -// empty argv. +var droidBinarySpec = binaryutil.BinarySpec{ + Label: "droid", + Names: []string{"droid"}, + WinNames: []string{"droid.cmd", "droid.exe", "droid"}, + UnixPaths: []string{"/usr/local/bin/droid", "/opt/homebrew/bin/droid"}, + UnixHomePaths: [][]string{{".local", "bin", "droid"}, {".factory", "bin", "droid"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".local", "bin", "droid.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".factory", "bin", "droid.exe"}}, + }, +} + +// ResolveDroidBinary returns the path to the droid binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveDroidBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"droid.cmd", "droid.exe", "droid"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "droid.cmd"), - filepath.Join(appData, "npm", "droid.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "droid.exe"), - filepath.Join(home, ".factory", "bin", "droid.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("droid"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/droid", - "/opt/homebrew/bin/droid", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "droid"), - filepath.Join(home, ".factory", "bin", "droid"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, droidBinarySpec) } func (p *Plugin) droidBinary(ctx context.Context) (string, error) { @@ -333,21 +253,3 @@ func (p *Plugin) droidBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - // Empty or unrecognized: defer to Droid's own settings (no flag). - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/droid/droid_test.go b/backend/internal/adapters/agent/droid/droid_test.go index 2607372c3..2c47c3bae 100644 --- a/backend/internal/adapters/agent/droid/droid_test.go +++ b/backend/internal/adapters/agent/droid/droid_test.go @@ -178,8 +178,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "droid-ses-1", - droidTitleMetadataKey: "Fix login redirect", - droidSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", }, }) if err != nil { diff --git a/backend/internal/adapters/agent/droid/hooks.go b/backend/internal/adapters/agent/droid/hooks.go index 55c4f561c..4b55ebf4a 100644 --- a/backend/internal/adapters/agent/droid/hooks.go +++ b/backend/internal/adapters/agent/droid/hooks.go @@ -2,15 +2,9 @@ package droid import ( "context" - "encoding/json" - "errors" - "fmt" - "os" "path/filepath" - "sort" - "strings" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -18,49 +12,20 @@ const ( droidSettingsDirName = ".factory" droidHooksFileName = "hooks.json" - // droidHookCommandPrefix identifies the hook commands AO owns. Every managed - // command starts with it, so install can skip duplicates and uninstall can - // recognize AO entries by prefix without an embedded template to diff - // against. The CLI dispatcher routes `ao hooks droid ` to the Droid - // activity deriver. + // droidHookCommandPrefix identifies the hook commands AO owns, so install + // skips duplicates and uninstall recognizes AO entries by prefix. droidHookCommandPrefix = "ao hooks droid " droidHookTimeout = 30 ) -type droidMatcherGroup struct { - // Matcher is a pointer so it round-trips exactly: SessionStart serializes - // with its "startup" matcher; UserPromptSubmit/Stop/Notification/SessionEnd - // omit it (Droid ignores matcher for those events). omitempty drops a nil - // matcher on write. - Matcher *string `json:"matcher,omitempty"` - Hooks []droidHookEntry `json:"hooks"` -} - -type droidHookEntry struct { - Type string `json:"type"` - Command string `json:"command"` - Timeout int `json:"timeout,omitempty"` -} - -// droidHookSpec describes one hook AO installs, defined in code rather than read -// from an embedded settings file. -type droidHookSpec struct { - Event string - Matcher *string - Command string -} - // droidStartupMatcher is referenced by pointer so SessionStart serializes with // its "startup" source matcher. var droidStartupMatcher = "startup" // droidManagedHooks is the source of truth for the hooks AO installs: // SessionStart (under the "startup" matcher), UserPromptSubmit, Stop, -// Notification, and SessionEnd. They report normalized activity-state signals -// back into AO's store (see DeriveActivityState). The non-SessionStart events -// carry no matcher: each installs once and fires for every sub-type, and the -// handler filters on the payload where it must. -var droidManagedHooks = []droidHookSpec{ +// Notification, and SessionEnd. +var droidManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Matcher: &droidStartupMatcher, Command: droidHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: droidHookCommandPrefix + "user-prompt-submit"}, {Event: "Stop", Command: droidHookCommandPrefix + "stop"}, @@ -68,287 +33,30 @@ var droidManagedHooks = []droidHookSpec{ {Event: "SessionEnd", Command: droidHookCommandPrefix + "session-end"}, } -// GetAgentHooks installs AO's Droid hooks into the worktree-local -// .factory/hooks.json file (the project-scope hooks config Droid reads). The -// hooks report normalized activity-state signals back into AO's store. Existing -// hooks and unrelated keys are preserved, and duplicate AO commands are not -// appended, so the install is idempotent. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(cfg.WorkspacePath) == "" { - return errors.New("droid.GetAgentHooks: WorkspacePath is required") - } - - hooksPath := droidHooksPath(cfg.WorkspacePath) - topLevel, rawHooks, err := readDroidHooks(hooksPath) - if err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - - byEvent := groupDroidHooksByEvent() - events := make([]string, 0, len(byEvent)) - for event := range byEvent { - events = append(events, event) - } - sort.Strings(events) - for _, event := range events { - specs := byEvent[event] - var existingGroups []droidMatcherGroup - if err := parseDroidHookType(rawHooks, event, &existingGroups); err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - for _, spec := range specs { - if !droidHookCommandExists(existingGroups, spec.Command) { - entry := droidHookEntry{Type: "command", Command: spec.Command, Timeout: droidHookTimeout} - existingGroups = addDroidHook(existingGroups, entry, spec.Matcher) - } - } - if err := marshalDroidHookType(rawHooks, event, existingGroups); err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - } - - if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), droidHooksFileName); err != nil { - return fmt.Errorf("droid.GetAgentHooks: gitignore: %w", err) - } - return nil -} - -// UninstallHooks removes AO's Droid hooks from the workspace-local -// .factory/hooks.json file, leaving user-defined hooks and unrelated keys -// untouched. A missing file is a no-op. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(workspacePath) == "" { - return errors.New("droid.UninstallHooks: workspacePath is required") - } - - hooksPath := droidHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return nil - } - topLevel, rawHooks, err := readDroidHooks(hooksPath) - if err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - - for _, event := range droidManagedEvents() { - var groups []droidMatcherGroup - if err := parseDroidHookType(rawHooks, event, &groups); err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - groups = removeDroidManagedHooks(groups) - if err := marshalDroidHookType(rawHooks, event, groups); err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - } - - if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - return nil -} - -// AreHooksInstalled reports whether any AO Droid hook is present in the -// workspace-local hooks file. A missing file means none are installed. -func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - if strings.TrimSpace(workspacePath) == "" { - return false, errors.New("droid.AreHooksInstalled: workspacePath is required") - } - - hooksPath := droidHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return false, nil - } - _, rawHooks, err := readDroidHooks(hooksPath) - if err != nil { - return false, fmt.Errorf("droid.AreHooksInstalled: %w", err) - } - - for _, event := range droidManagedEvents() { - var groups []droidMatcherGroup - if err := parseDroidHookType(rawHooks, event, &groups); err != nil { - return false, fmt.Errorf("droid.AreHooksInstalled: %w", err) - } - for _, group := range groups { - for _, hook := range group.Hooks { - if isDroidManagedHook(hook.Command) { - return true, nil - } - } - } - } - return false, nil +// droidHooks manages AO's hooks in the workspace-local .factory/hooks.json file. +var droidHooks = hooksjson.Manager{ + Label: "droid", + CommandPrefix: droidHookCommandPrefix, + Timeout: droidHookTimeout, + Path: droidHooksPath, + Managed: droidManagedHooks, } func droidHooksPath(workspacePath string) string { return filepath.Join(workspacePath, droidSettingsDirName, droidHooksFileName) } -// readDroidHooks loads the hooks file into a top-level raw map plus the decoded -// "hooks" sub-map, preserving every key AO doesn't manage. A missing or empty -// file yields empty maps. -func readDroidHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { - topLevel = map[string]json.RawMessage{} - rawHooks = map[string]json.RawMessage{} - - data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir - if errors.Is(err, os.ErrNotExist) { - return topLevel, rawHooks, nil - } - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) - } - if strings.TrimSpace(string(data)) == "" { - return topLevel, rawHooks, nil - } - if err := json.Unmarshal(data, &topLevel); err != nil { - return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) - } - if hooksRaw, ok := topLevel["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) - } - } - return topLevel, rawHooks, nil +// GetAgentHooks installs AO's Droid hooks, preserving user-defined hooks. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return droidHooks.Install(ctx, cfg.WorkspacePath) } -// writeDroidHooks folds rawHooks back into topLevel and writes the file. An -// empty hooks map drops the "hooks" key entirely. -func writeDroidHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { - if len(rawHooks) == 0 { - delete(topLevel, "hooks") - } else { - hooksJSON, err := json.Marshal(rawHooks) - if err != nil { - return fmt.Errorf("encode hooks: %w", err) - } - topLevel["hooks"] = hooksJSON - } - - if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { - return fmt.Errorf("create hooks dir: %w", err) - } - data, err := json.MarshalIndent(topLevel, "", " ") - if err != nil { - return fmt.Errorf("encode %s: %w", hooksPath, err) - } - data = append(data, '\n') - if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { - return fmt.Errorf("write %s: %w", hooksPath, err) - } - return nil +// UninstallHooks removes AO's Droid hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return droidHooks.Uninstall(ctx, workspacePath) } -// groupDroidHooksByEvent groups the managed hook specs by their Droid event so -// each event's array is rewritten once. -func groupDroidHooksByEvent() map[string][]droidHookSpec { - byEvent := map[string][]droidHookSpec{} - for _, spec := range droidManagedHooks { - byEvent[spec.Event] = append(byEvent[spec.Event], spec) - } - return byEvent -} - -// droidManagedEvents returns the distinct Droid events AO manages, in the order -// they first appear in droidManagedHooks. -func droidManagedEvents() []string { - seen := map[string]bool{} - events := make([]string, 0, len(droidManagedHooks)) - for _, spec := range droidManagedHooks { - if !seen[spec.Event] { - seen[spec.Event] = true - events = append(events, spec.Event) - } - } - return events -} - -func isDroidManagedHook(command string) bool { - return strings.HasPrefix(command, droidHookCommandPrefix) -} - -// removeDroidManagedHooks strips AO hook entries from every group, dropping any -// group left without hooks so the event array doesn't accumulate empty matcher -// objects. -func removeDroidManagedHooks(groups []droidMatcherGroup) []droidMatcherGroup { - result := make([]droidMatcherGroup, 0, len(groups)) - for _, group := range groups { - kept := make([]droidHookEntry, 0, len(group.Hooks)) - for _, hook := range group.Hooks { - if !isDroidManagedHook(hook.Command) { - kept = append(kept, hook) - } - } - if len(kept) > 0 { - group.Hooks = kept - result = append(result, group) - } - } - return result -} - -func parseDroidHookType(rawHooks map[string]json.RawMessage, event string, target *[]droidMatcherGroup) error { - data, ok := rawHooks[event] - if !ok { - return nil - } - if err := json.Unmarshal(data, target); err != nil { - return fmt.Errorf("parse %s hooks: %w", event, err) - } - return nil -} - -func marshalDroidHookType(rawHooks map[string]json.RawMessage, event string, groups []droidMatcherGroup) error { - if len(groups) == 0 { - delete(rawHooks, event) - return nil - } - data, err := json.Marshal(groups) - if err != nil { - return fmt.Errorf("encode %s hooks: %w", event, err) - } - rawHooks[event] = data - return nil -} - -func droidHookCommandExists(groups []droidMatcherGroup, command string) bool { - for _, group := range groups { - for _, hook := range group.Hooks { - if hook.Command == command { - return true - } - } - } - return false -} - -// addDroidHook appends hook to an existing group with the same matcher (so a -// SessionStart hook lands under its "startup" matcher), creating that group if -// none matches. -func addDroidHook(groups []droidMatcherGroup, hook droidHookEntry, matcher *string) []droidMatcherGroup { - for i, group := range groups { - if matchersEqual(group.Matcher, matcher) { - groups[i].Hooks = append(groups[i].Hooks, hook) - return groups - } - } - return append(groups, droidMatcherGroup{Matcher: matcher, Hooks: []droidHookEntry{hook}}) -} - -func matchersEqual(a, b *string) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return *a == *b +// AreHooksInstalled reports whether any AO Droid hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return droidHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/droid/install.go b/backend/internal/adapters/agent/droid/install.go new file mode 100644 index 000000000..b67c2e856 --- /dev/null +++ b/backend/internal/adapters/agent/droid/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/goose/activity.go b/backend/internal/adapters/agent/goose/activity.go deleted file mode 100644 index 183568925..000000000 --- a/backend/internal/adapters/agent/goose/activity.go +++ /dev/null @@ -1,35 +0,0 @@ -package goose - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Goose hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in gooseManagedHooks -// ("session-start", "user-prompt-submit", "stop", "permission-request"), not -// the native Goose event name. -// -// Goose's native hook surface (as of 2026-05) emits SessionStart / -// UserPromptSubmit / Stop / SessionEnd plus the tool-use events, but has no -// dedicated permission/approval event yet, so AO does not install a -// "permission-request" hook today. The case is kept here so that, if a future -// Goose release adds an approval lifecycle event, mapping it to waiting_input is -// a one-line hooks.go change with no deriver edit needed. -// -// TODO(goose): ActivityExited is still runtime-observation-owned. Goose has a -// native SessionEnd hook; if AO starts installing it, map it to ActivityExited -// here. Until then, the lifecycle reaper marks a dead Goose runtime as exited. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/goose/activity_test.go b/backend/internal/adapters/agent/goose/activity_test.go deleted file mode 100644 index 224ac8a4e..000000000 --- a/backend/internal/adapters/agent/goose/activity_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package goose - -import ( - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - name string - event string - want domain.ActivityState - wantOK bool - }{ - {"session start -> active", "session-start", domain.ActivityActive, true}, - {"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true}, - {"stop -> idle", "stop", domain.ActivityIdle, true}, - {"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true}, - {"unknown event -> no signal", "frobnicate", "", false}, - {"empty event -> no signal", "", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, []byte(`{}`)) - if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", - tt.event, got, ok, tt.want, tt.wantOK) - } - }) - } -} diff --git a/backend/internal/adapters/agent/goose/auth.go b/backend/internal/adapters/agent/goose/auth.go new file mode 100644 index 000000000..76afd2a9f --- /dev/null +++ b/backend/internal/adapters/agent/goose/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/goose/goose.go b/backend/internal/adapters/agent/goose/goose.go index c0f9c5ab6..f2f81768c 100644 --- a/backend/internal/adapters/agent/goose/goose.go +++ b/backend/internal/adapters/agent/goose/goose.go @@ -25,22 +25,18 @@ import ( "context" "fmt" "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( adapterID = "goose" - gooseTitleMetadataKey = "title" - gooseSummaryMetadataKey = "summary" - // gooseModeEnvVar is the only permission-control surface Goose honors: the // approval mode is read from this process env var, not from any CLI flag. gooseModeEnvVar = "GOOSE_MODE" @@ -49,6 +45,7 @@ const ( // Plugin is the Goose agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -74,22 +71,17 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Goose exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new headless Goose session: // -// [env GOOSE_MODE=] goose run [--system ] [-t ] +// [env GOOSE_MODE=] goose run [--system ] -t // // The prompt is delivered in-command via `-t`. A non-default permission mode is // rendered as an `env GOOSE_MODE=` prefix because Goose reads its approval // mode from the environment, not from a flag. System instructions, when present, -// are passed via `--system`. +// are passed via `--system`. Goose requires one of --instructions, --text, or +// --recipe even when AO intentionally starts a promptless orchestrator, so empty +// prompts are delivered as `-t "" --interactive` to land in an input-ready +// terminal without inventing an initial task. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.gooseBinary(ctx) if err != nil { @@ -106,22 +98,14 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( cmd = append(cmd, "--system", systemPrompt) } - if cfg.Prompt != "" { - cmd = append(cmd, "-t", cfg.Prompt) + cmd = append(cmd, "-t", cfg.Prompt) + if cfg.Prompt == "" { + cmd = append(cmd, "--interactive") } return cmd, nil } -// GetPromptDeliveryStrategy reports that Goose receives its prompt in the launch -// command itself (via `-t`). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Goose session: // // [env GOOSE_MODE=] goose run --resume --session-id @@ -152,15 +136,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[gooseTitleMetadataKey], - Summary: session.Metadata[gooseSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // systemPromptText returns the system instructions to inject. Goose's `--system` @@ -206,7 +183,7 @@ func gooseModeEnvPrefix(mode ports.PermissionMode) []string { // - bypass-permissions → auto: Goose's fully-autonomous mode is the nearest // equivalent to bypass. func gooseMode(mode ports.PermissionMode) string { - switch normalizePermissionMode(mode) { + switch ports.NormalizePermissionMode(mode) { case ports.PermissionModeAcceptEdits: return "smart_approve" case ports.PermissionModeAuto: @@ -218,90 +195,26 @@ func gooseMode(mode ports.PermissionMode) string { } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - // Empty or unrecognized: defer to Goose's own config (no env). - return ports.PermissionModeDefault - } +// gooseBinarySpec locates the goose binary: PATH first, then the install +// script's ~/.local/bin, Homebrew, Cargo, and npm global locations. +var gooseBinarySpec = binaryutil.BinarySpec{ + Label: "goose", + Names: []string{"goose"}, + WinNames: []string{"goose.cmd", "goose.exe", "goose"}, + UnixPaths: []string{"/usr/local/bin/goose", "/opt/homebrew/bin/goose"}, + UnixHomePaths: [][]string{{".local", "bin", "goose"}, {".cargo", "bin", "goose"}, {".npm", "bin", "goose"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "goose.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "goose.exe"}}, + {Base: binaryutil.WinLocalAppData, Parts: []string{"Programs", "goose", "goose.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "goose.exe"}}, + }, } -// ResolveGooseBinary returns the path to the goose binary on this machine, -// searching PATH then a handful of well-known install locations (the install -// script's ~/.local/bin, Homebrew, Cargo, npm global). Returns "goose" as a -// last-ditch fallback so callers see a clear "command not found" rather than an -// empty argv. +// ResolveGooseBinary returns the path to the goose binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveGooseBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"goose.cmd", "goose.exe", "goose"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "goose.cmd"), - filepath.Join(appData, "npm", "goose.exe"), - ) - } - if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { - candidates = append(candidates, filepath.Join(localAppData, "Programs", "goose", "goose.exe")) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "goose.exe")) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("goose: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("goose"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/goose", - "/opt/homebrew/bin/goose", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "goose"), - filepath.Join(home, ".cargo", "bin", "goose"), - filepath.Join(home, ".npm", "bin", "goose"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("goose: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, gooseBinarySpec) } func (p *Plugin) gooseBinary(ctx context.Context) (string, error) { @@ -319,8 +232,3 @@ func (p *Plugin) gooseBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/goose/goose_test.go b/backend/internal/adapters/agent/goose/goose_test.go index c5adc2016..c9f8ac562 100644 --- a/backend/internal/adapters/agent/goose/goose_test.go +++ b/backend/internal/adapters/agent/goose/goose_test.go @@ -9,6 +9,7 @@ import ( "reflect" "testing" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -71,6 +72,22 @@ func TestGetLaunchCommandSystemPromptFileInlined(t *testing.T) { } } +func TestGetLaunchCommandPromptlessLaunchStaysInteractive(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPrompt: "coordinate this project", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"goose", "run", "--system", "coordinate this project", "-t", "", "--interactive"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string @@ -125,7 +142,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "goose"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -137,7 +154,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "goose"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -148,6 +165,73 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { } } +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + clearGooseAuthEnv(t) + t.Setenv("OPENROUTER_API_KEY", "test-key") + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusAuthorizedFromGooseConfig(t *testing.T) { + clearGooseAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + configPath := filepath.Join(home, ".config", "goose", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte("providers:\n openrouter:\n configured: true\n model: anthropic/claude-sonnet-4\n"), 0o600); err != nil { + t.Fatal(err) + } + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusUnauthorizedFromEmptyGooseConfig(t *testing.T) { + clearGooseAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + configPath := filepath.Join(home, ".config", "goose", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusUnauthorized) + } +} + +func clearGooseAuthEnv(t *testing.T) { + t.Helper() + for _, name := range gooseAPIKeyEnvVars { + t.Setenv(name, "") + } +} + func TestContextCancellationIsHonored(t *testing.T) { plugin := &Plugin{resolvedBinary: "goose"} ctx, cancel := context.WithCancel(context.Background()) @@ -336,8 +420,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "thread-123", - gooseTitleMetadataKey: "Fix login redirect", - gooseSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -428,7 +512,13 @@ func containsSubsequence(values []string, needle []string) bool { return false } -func countGooseHookCommand(entries []gooseMatcherGroup, command string) int { +// gooseHookFile is the on-disk shape of the hooks file, used to decode and +// assert on what GetAgentHooks wrote. +type gooseHookFile struct { + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` +} + +func countGooseHookCommand(entries []hooksjson.MatcherGroup, command string) int { count := 0 for _, entry := range entries { for _, hook := range entry.Hooks { diff --git a/backend/internal/adapters/agent/goose/hooks.go b/backend/internal/adapters/agent/goose/hooks.go index f631659e5..da935f14b 100644 --- a/backend/internal/adapters/agent/goose/hooks.go +++ b/backend/internal/adapters/agent/goose/hooks.go @@ -2,22 +2,16 @@ package goose import ( "context" - "encoding/json" - "errors" - "fmt" - "os" "path/filepath" - "strings" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( - // goosePluginDirName is the AO plugin directory under a workspace's - // .agents/plugins/. Goose auto-discovers any plugin dir containing a - // hooks/hooks.json at startup; unlike Codex there is no separate feature - // flag to toggle, so installing the file is sufficient. + // Goose auto-discovers any plugin dir containing a hooks/hooks.json at + // startup; unlike Codex there is no separate feature flag to toggle, so + // installing the file is sufficient. gooseHooksRootDirName = ".agents" goosePluginsDirName = "plugins" goosePluginName = "ao" @@ -25,332 +19,45 @@ const ( gooseHooksFileName = "hooks.json" // gooseHookCommandPrefix identifies the hook commands AO owns, so install - // skips duplicates and uninstall recognizes AO entries by prefix without an - // embedded template to diff against. + // skips duplicates and uninstall recognizes AO entries by prefix. gooseHookCommandPrefix = "ao hooks goose " gooseHookTimeout = 30 ) -// gooseHookFile is the on-disk shape of .agents/plugins/ao/hooks/hooks.json. It -// is used by tests to decode the written file. -type gooseHookFile struct { - Hooks map[string][]gooseMatcherGroup `json:"hooks"` -} - -type gooseMatcherGroup struct { - Matcher *string `json:"matcher,omitempty"` - Hooks []gooseHookEntry `json:"hooks"` -} - -type gooseHookEntry struct { - Type string `json:"type"` - Command string `json:"command"` - Timeout int `json:"timeout,omitempty"` -} - -// gooseHookSpec describes one hook AO installs, defined in code rather than read -// from an embedded hooks file. -type gooseHookSpec struct { - Event string - Command string -} - // gooseManagedHooks is the source of truth for the hooks AO installs. Goose // groups every hook under the nil matcher. Goose has no permission/approval // lifecycle event yet, so AO installs only the session/prompt/stop signals. -var gooseManagedHooks = []gooseHookSpec{ +var gooseManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Command: gooseHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: gooseHookCommandPrefix + "user-prompt-submit"}, {Event: "Stop", Command: gooseHookCommandPrefix + "stop"}, } -// GetAgentHooks installs AO's Goose hooks into the worktree-local -// .agents/plugins/ao/hooks/hooks.json file. Existing hook entries are preserved -// and duplicate AO commands are not appended. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(cfg.WorkspacePath) == "" { - return errors.New("goose.GetAgentHooks: WorkspacePath is required") - } - - hooksPath := gooseHooksPath(cfg.WorkspacePath) - topLevel, rawHooks, err := readGooseHooks(hooksPath) - if err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - - for event, specs := range groupGooseHooksByEvent() { - var existingGroups []gooseMatcherGroup - if err := parseGooseHookType(rawHooks, event, &existingGroups); err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - for _, spec := range specs { - if !gooseHookCommandExists(existingGroups, spec.Command) { - entry := gooseHookEntry{Type: "command", Command: spec.Command, Timeout: gooseHookTimeout} - existingGroups = addGooseHook(existingGroups, entry) - } - } - if err := marshalGooseHookType(rawHooks, event, existingGroups); err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - } - - if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), gooseHooksFileName); err != nil { - return fmt.Errorf("goose.GetAgentHooks: gitignore: %w", err) - } - return nil -} - -// UninstallHooks removes AO's Goose hooks from the workspace-local -// .agents/plugins/ao/hooks/hooks.json file, leaving user-defined hooks -// untouched. A missing file is a no-op. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(workspacePath) == "" { - return errors.New("goose.UninstallHooks: workspacePath is required") - } - - hooksPath := gooseHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return nil - } - topLevel, rawHooks, err := readGooseHooks(hooksPath) - if err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - - for _, event := range gooseManagedEvents() { - var groups []gooseMatcherGroup - if err := parseGooseHookType(rawHooks, event, &groups); err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - groups = removeGooseManagedHooks(groups) - if err := marshalGooseHookType(rawHooks, event, groups); err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - } - - if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - return nil -} - -// AreHooksInstalled reports whether any AO Goose hook is present in the -// workspace-local hooks file. A missing file means none are installed. -func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - if strings.TrimSpace(workspacePath) == "" { - return false, errors.New("goose.AreHooksInstalled: workspacePath is required") - } - - hooksPath := gooseHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return false, nil - } - _, rawHooks, err := readGooseHooks(hooksPath) - if err != nil { - return false, fmt.Errorf("goose.AreHooksInstalled: %w", err) - } - - for _, event := range gooseManagedEvents() { - var groups []gooseMatcherGroup - if err := parseGooseHookType(rawHooks, event, &groups); err != nil { - return false, fmt.Errorf("goose.AreHooksInstalled: %w", err) - } - for _, group := range groups { - for _, hook := range group.Hooks { - if isGooseManagedHook(hook.Command) { - return true, nil - } - } - } - } - return false, nil +// gooseHooks manages AO's hooks in the workspace-local +// .agents/plugins/ao/hooks/hooks.json file. +var gooseHooks = hooksjson.Manager{ + Label: "goose", + CommandPrefix: gooseHookCommandPrefix, + Timeout: gooseHookTimeout, + Path: gooseHooksPath, + Managed: gooseManagedHooks, } func gooseHooksPath(workspacePath string) string { return filepath.Join(workspacePath, gooseHooksRootDirName, goosePluginsDirName, goosePluginName, gooseHooksSubDirName, gooseHooksFileName) } -// readGooseHooks loads the hooks file into a top-level raw map plus the decoded -// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty file -// yields empty maps. -func readGooseHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { - topLevel = map[string]json.RawMessage{} - rawHooks = map[string]json.RawMessage{} - - data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir - if errors.Is(err, os.ErrNotExist) { - return topLevel, rawHooks, nil - } - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) - } - if strings.TrimSpace(string(data)) == "" { - return topLevel, rawHooks, nil - } - if err := json.Unmarshal(data, &topLevel); err != nil { - return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) - } - if hooksRaw, ok := topLevel["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) - } - } - return topLevel, rawHooks, nil +// GetAgentHooks installs AO's Goose hooks, preserving user-defined hooks. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return gooseHooks.Install(ctx, cfg.WorkspacePath) } -// writeGooseHooks folds rawHooks back into topLevel and writes the file. An -// empty hooks map drops the "hooks" key entirely. -func writeGooseHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { - if len(rawHooks) == 0 { - delete(topLevel, "hooks") - } else { - hooksJSON, err := json.Marshal(rawHooks) - if err != nil { - return fmt.Errorf("encode hooks: %w", err) - } - topLevel["hooks"] = hooksJSON - } - - if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { - return fmt.Errorf("create hook dir: %w", err) - } - data, err := json.MarshalIndent(topLevel, "", " ") - if err != nil { - return fmt.Errorf("encode %s: %w", hooksPath, err) - } - data = append(data, '\n') - if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { - return fmt.Errorf("write %s: %w", hooksPath, err) - } - return nil +// UninstallHooks removes AO's Goose hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return gooseHooks.Uninstall(ctx, workspacePath) } -// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- -// write can't leave a truncated/empty file that Goose then fails to parse. -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} - -// groupGooseHooksByEvent groups the managed hook specs by their Goose event so -// each event's array is rewritten once. -func groupGooseHooksByEvent() map[string][]gooseHookSpec { - byEvent := map[string][]gooseHookSpec{} - for _, spec := range gooseManagedHooks { - byEvent[spec.Event] = append(byEvent[spec.Event], spec) - } - return byEvent -} - -// gooseManagedEvents returns the distinct Goose events AO manages, in the order -// they first appear in gooseManagedHooks. -func gooseManagedEvents() []string { - seen := map[string]bool{} - events := make([]string, 0, len(gooseManagedHooks)) - for _, spec := range gooseManagedHooks { - if !seen[spec.Event] { - seen[spec.Event] = true - events = append(events, spec.Event) - } - } - return events -} - -func isGooseManagedHook(command string) bool { - return strings.HasPrefix(command, gooseHookCommandPrefix) -} - -// removeGooseManagedHooks strips AO hook entries from every group, dropping any -// group left without hooks. -func removeGooseManagedHooks(groups []gooseMatcherGroup) []gooseMatcherGroup { - result := make([]gooseMatcherGroup, 0, len(groups)) - for _, group := range groups { - kept := make([]gooseHookEntry, 0, len(group.Hooks)) - for _, hook := range group.Hooks { - if !isGooseManagedHook(hook.Command) { - kept = append(kept, hook) - } - } - if len(kept) > 0 { - group.Hooks = kept - result = append(result, group) - } - } - return result -} - -func parseGooseHookType(rawHooks map[string]json.RawMessage, event string, target *[]gooseMatcherGroup) error { - data, ok := rawHooks[event] - if !ok { - return nil - } - if err := json.Unmarshal(data, target); err != nil { - return fmt.Errorf("parse %s hooks: %w", event, err) - } - return nil -} - -func marshalGooseHookType(rawHooks map[string]json.RawMessage, event string, groups []gooseMatcherGroup) error { - if len(groups) == 0 { - delete(rawHooks, event) - return nil - } - data, err := json.Marshal(groups) - if err != nil { - return fmt.Errorf("encode %s hooks: %w", event, err) - } - rawHooks[event] = data - return nil -} - -func gooseHookCommandExists(groups []gooseMatcherGroup, command string) bool { - for _, group := range groups { - for _, hook := range group.Hooks { - if hook.Command == command { - return true - } - } - } - return false -} - -func addGooseHook(groups []gooseMatcherGroup, hook gooseHookEntry) []gooseMatcherGroup { - for i, group := range groups { - if group.Matcher == nil { - groups[i].Hooks = append(groups[i].Hooks, hook) - return groups - } - } - return append(groups, gooseMatcherGroup{ - Matcher: nil, - Hooks: []gooseHookEntry{hook}, - }) +// AreHooksInstalled reports whether any AO Goose hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return gooseHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/goose/install.go b/backend/internal/adapters/agent/goose/install.go new file mode 100644 index 000000000..3b3cb45ca --- /dev/null +++ b/backend/internal/adapters/agent/goose/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/grok/auth.go b/backend/internal/adapters/agent/grok/auth.go new file mode 100644 index 000000000..3f1fd402a --- /dev/null +++ b/backend/internal/adapters/agent/grok/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/grok/auth_test.go b/backend/internal/adapters/agent/grok/auth_test.go new file mode 100644 index 000000000..28ec92005 --- /dev/null +++ b/backend/internal/adapters/agent/grok/auth_test.go @@ -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) + } +} diff --git a/backend/internal/adapters/agent/grok/grok.go b/backend/internal/adapters/agent/grok/grok.go index ddebb78a0..0f5ca4cfb 100644 --- a/backend/internal/adapters/agent/grok/grok.go +++ b/backend/internal/adapters/agent/grok/grok.go @@ -16,21 +16,32 @@ package grok import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) +var grokBinarySpec = binaryutil.BinarySpec{ + Label: "grok", + Names: []string{"grok"}, + WinNames: []string{"grok.cmd", "grok.exe", "grok"}, + UnixPaths: []string{"/usr/local/bin/grok", "/opt/homebrew/bin/grok"}, + UnixHomePaths: [][]string{{".grok", "bin", "grok"}, {".local", "bin", "grok"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "grok.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "grok.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".grok", "bin", "grok.exe"}}, + }, +} + // Plugin is the Grok Build agent adapter. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -56,14 +67,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds `grok --no-auto-update [--permission-mode ] -p `. // Prompt is delivered via -p (in command). // @@ -85,14 +88,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetAgentHooks reuses the Claude Code hook installer because Grok Build // has a full Claude Code compatibility layer. // @@ -166,81 +161,13 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - // The keys written by claude hooks (which we install for grok too). - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveGrokBinary finds the `grok` binary (xAI Grok Build CLI). func ResolveGrokBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"grok.cmd", "grok.exe", "grok"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "grok.cmd"), - filepath.Join(appData, "npm", "grok.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".grok", "bin", "grok.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("grok: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("grok"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/grok", - "/opt/homebrew/bin/grok", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".grok", "bin", "grok"), - filepath.Join(home, ".local", "bin", "grok"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("grok: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, grokBinarySpec) } func (p *Plugin) grokBinary(ctx context.Context) (string, error) { @@ -260,7 +187,7 @@ func (p *Plugin) grokBinary(ctx context.Context) (string, error) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's ~/.grok/config.toml (or default behavior). case ports.PermissionModeAcceptEdits: @@ -271,20 +198,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--permission-mode", "bypassPermissions") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/grok/install.go b/backend/internal/adapters/agent/grok/install.go new file mode 100644 index 000000000..2af44fde4 --- /dev/null +++ b/backend/internal/adapters/agent/grok/install.go @@ -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) +} diff --git a/backend/internal/adapters/agent/hooksjson/hooksjson.go b/backend/internal/adapters/agent/hooksjson/hooksjson.go new file mode 100644 index 000000000..21d497b54 --- /dev/null +++ b/backend/internal/adapters/agent/hooksjson/hooksjson.go @@ -0,0 +1,332 @@ +// Package hooksjson implements the matcher-group hooks file that several agents +// (claude-code, goose, qwen, agy, droid) share byte-for-byte in shape. Each such +// file is a JSON object with a "hooks" sub-map keyed by native event name, whose +// values are matcher groups ({matcher?, hooks:[{type,command,timeout}]}). The +// adapters differed only in the file path, the AO command prefix, the per-hook +// timeout, and which events they install, so they describe those with a Manager +// and share the install/uninstall/detect logic here. +// +// The read/write path preserves every top-level key and every user-defined hook +// AO does not own, and writes atomically, so installing AO's hooks never clobbers +// unrelated settings. +package hooksjson + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" +) + +// HookEntry is one command hook inside a matcher group. +type HookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// MatcherGroup is a set of hooks sharing one matcher. Matcher is a pointer so it +// round-trips exactly: events that require a matcher (e.g. claude SessionStart's +// "startup") carry one; events that omit it serialize without the key. +type MatcherGroup struct { + Matcher *string `json:"matcher,omitempty"` + Hooks []HookEntry `json:"hooks"` +} + +// HookSpec describes one hook AO installs: the native event it attaches to, its +// optional matcher, and the command to run. Adapters define these in code rather +// than reading an embedded template. +type HookSpec struct { + Event string + Matcher *string + Command string +} + +// Manager installs, removes, and detects AO's hooks in one agent's matcher-group +// hooks file. Construct one per adapter with its file path, command prefix, +// per-hook timeout, and managed hook set. +type Manager struct { + // Label prefixes error messages, e.g. "claude-code" or "goose", so the + // wrapped error reads "