Merge main and simplify repository setup prompt
This commit is contained in:
commit
b128d5f1d5
|
|
@ -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<that>. 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 .
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
# Contributing
|
||||
|
||||
We love contributions! Join our community on Discord to get started.
|
||||
|
||||
## Join us on Discord
|
||||
|
||||
[](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
|
||||
50
README.md
50
README.md
|
|
@ -16,6 +16,49 @@ An Agentic IDE that supervises parallel AI coding agents in isolated workspaces,
|
|||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
### Witness AO's Journey on X
|
||||
|
||||
<table border="1" style="border-collapse: collapse; width: 100%;">
|
||||
|
|
@ -41,7 +84,7 @@ An Agentic IDE that supervises parallel AI coding agents in isolated workspaces,
|
|||
</tr>
|
||||
</table>
|
||||
|
||||
[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)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <agent> <event>` 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 <WorkspacePath> [--dangerously-skip-permissions] --conversation <agentSessionId>`.
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
package agy
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
if _, err := p.ResolveBinary(ctx); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
return ports.AgentAuthStatusAuthorized, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package agy
|
||||
|
||||
import "context"
|
||||
|
||||
// ResolveBinary resolves the executable path for the plugin.
|
||||
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
|
||||
return p.agyBinary(ctx)
|
||||
}
|
||||
|
|
@ -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 <prompt> [permission flags] --no-check-update --no-stream --no-pretty [--read <system prompt file>]
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
package aider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
if _, err := p.ResolveBinary(ctx); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if status, ok, err := aiderLocalAuthStatus(ctx); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
} else if ok {
|
||||
return status, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
|
||||
var aiderAPIKeyEnvVars = []string{
|
||||
"AIDER_API_KEY",
|
||||
"AIDER_OPENAI_API_KEY",
|
||||
"AIDER_ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"GROQ_API_KEY",
|
||||
"XAI_API_KEY",
|
||||
"MISTRAL_API_KEY",
|
||||
"COHERE_API_KEY",
|
||||
}
|
||||
|
||||
func aiderLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
for _, name := range aiderAPIKeyEnvVars {
|
||||
if strings.TrimSpace(os.Getenv(name)) != "" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range aiderConfigPaths() {
|
||||
status, ok, err := aiderAuthStatusFromFile(path)
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if ok {
|
||||
return status, true, nil
|
||||
}
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func aiderConfigPaths() []string {
|
||||
paths := []string{}
|
||||
if cwd, err := os.Getwd(); err == nil && cwd != "" {
|
||||
paths = append(paths,
|
||||
filepath.Join(cwd, ".env"),
|
||||
filepath.Join(cwd, ".aider.conf.yml"),
|
||||
filepath.Join(cwd, ".aider.conf.yaml"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
paths = append(paths,
|
||||
filepath.Join(home, ".env"),
|
||||
filepath.Join(home, ".aider.conf.yml"),
|
||||
filepath.Join(home, ".aider.conf.yaml"),
|
||||
)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func aiderAuthStatusFromFile(path string) (ports.AgentAuthStatus, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if text == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
if fileContainsAPIKey(text) {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func fileContainsAPIKey(text string) bool {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
if !strings.Contains(lower, "api") || !strings.Contains(lower, "key") {
|
||||
continue
|
||||
}
|
||||
if valueAfterAssignment(line) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func valueAfterAssignment(line string) string {
|
||||
for _, sep := range []string{"=", ":"} {
|
||||
before, after, ok := strings.Cut(line, sep)
|
||||
if !ok || !strings.Contains(strings.ToLower(before), "key") {
|
||||
continue
|
||||
}
|
||||
value := strings.Trim(strings.TrimSpace(after), `"'`)
|
||||
if value != "" && !strings.EqualFold(value, "null") && !strings.EqualFold(value, "none") {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package aider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestAiderLocalAuthStatusAuthorizedWithProviderEnv(t *testing.T) {
|
||||
clearAiderAuthEnv(t)
|
||||
t.Setenv("OPENAI_API_KEY", "sk-test")
|
||||
|
||||
status, ok, err := aiderLocalAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAiderLocalAuthStatusAuthorizedWithConfigFile(t *testing.T) {
|
||||
clearAiderAuthEnv(t)
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
if err := os.WriteFile(filepath.Join(home, ".aider.conf.yml"), []byte("openai-api-key: sk-test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := aiderLocalAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAiderLocalAuthStatusAuthorizedWithDotEnv(t *testing.T) {
|
||||
clearAiderAuthEnv(t)
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
if err := os.WriteFile(filepath.Join(home, ".env"), []byte("ANTHROPIC_API_KEY=sk-ant-test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := aiderLocalAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAiderLocalAuthStatusUnknownWhenMissing(t *testing.T) {
|
||||
clearAiderAuthEnv(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
status, ok, err := aiderLocalAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok || status != ports.AgentAuthStatusUnknown {
|
||||
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func clearAiderAuthEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range aiderAPIKeyEnvVars {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package aider
|
||||
|
||||
import "context"
|
||||
|
||||
// ResolveBinary resolves the executable path for the plugin.
|
||||
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
|
||||
return p.aiderBinary(ctx)
|
||||
}
|
||||
|
|
@ -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 <mode>] [--append-system-prompt <system prompt>] [-- <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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
package amp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
binary, err := p.ResolveBinary(ctx)
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if status, ok, err := ampLocalAuthStatus(ctx); err != nil || ok {
|
||||
return status, err
|
||||
}
|
||||
return ampUsageAuthStatus(ctx, binary)
|
||||
}
|
||||
|
||||
func ampLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("AMP_API_KEY")) != "" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
status, ok, err := ampSettingsAuthStatus(ampSettingsPath())
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
return status, ok, nil
|
||||
}
|
||||
|
||||
func ampSettingsPath() string {
|
||||
if path := strings.TrimSpace(os.Getenv("AMP_SETTINGS_FILE")); path != "" {
|
||||
return expandHome(path)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
return filepath.Join(home, ".config", "amp", "settings.json")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ampSettingsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
|
||||
if path == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
for _, key := range []string{"amp.apiKey", "amp.api_key", "apiKey", "api_key"} {
|
||||
if value, ok := settings[key]; ok {
|
||||
if strings.TrimSpace(stringValue(value)) != "" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnauthorized, true, nil
|
||||
}
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func ampUsageAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) {
|
||||
if binary == "" {
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := authprobe.CmdRunner(probeCtx, binary, "usage", "--no-color")
|
||||
if probeCtx.Err() != nil {
|
||||
return ports.AgentAuthStatusUnknown, probeCtx.Err()
|
||||
}
|
||||
if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown {
|
||||
return status, nil
|
||||
}
|
||||
if err == nil {
|
||||
return ports.AgentAuthStatusAuthorized, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func expandHome(path string) string {
|
||||
if path == "~" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return home
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, strings.TrimPrefix(path, "~/"))
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package amp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestAuthStatusAuthorizedFromEnv(t *testing.T) {
|
||||
t.Setenv("AMP_API_KEY", "amp-key")
|
||||
|
||||
got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpSettingsAuthStatusAuthorizedWithAPIKey(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(path, []byte(`{"amp.apiKey":"amp-key"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := ampSettingsAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpSettingsAuthStatusUnauthorizedWithEmptyAPIKey(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(path, []byte(`{"amp.apiKey":""}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := ampSettingsAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusUnauthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpUsageAuthStatusAuthorizedOnSuccessfulUsage(t *testing.T) {
|
||||
t.Setenv("AMP_API_KEY", "")
|
||||
t.Setenv("AMP_SETTINGS_FILE", filepath.Join(t.TempDir(), "missing-settings.json"))
|
||||
restore := mockAuthProbeRunner(t, func(ctx context.Context, name string, arg ...string) ([]byte, error) {
|
||||
if name != "amp" || !reflect.DeepEqual(arg, []string{"usage", "--no-color"}) {
|
||||
t.Fatalf("command = %s %#v, want amp usage --no-color", name, arg)
|
||||
}
|
||||
return []byte("Credits remaining: 100"), nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpUsageAuthStatusUnauthorizedFromUsageOutput(t *testing.T) {
|
||||
t.Setenv("AMP_API_KEY", "")
|
||||
t.Setenv("AMP_SETTINGS_FILE", filepath.Join(t.TempDir(), "missing-settings.json"))
|
||||
restore := mockAuthProbeRunner(t, func(ctx context.Context, name string, arg ...string) ([]byte, error) {
|
||||
return []byte("login required"), errors.New("exit status 1")
|
||||
})
|
||||
defer restore()
|
||||
|
||||
got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.AgentAuthStatusUnauthorized {
|
||||
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func mockAuthProbeRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() {
|
||||
t.Helper()
|
||||
previous := authprobe.CmdRunner
|
||||
authprobe.CmdRunner = runner
|
||||
return func() { authprobe.CmdRunner = previous }
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package amp
|
||||
|
||||
import "context"
|
||||
|
||||
// ResolveBinary resolves the executable path for the plugin.
|
||||
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
|
||||
return p.ampBinary(ctx)
|
||||
}
|
||||
|
|
@ -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 <f> | --instruction <s>] [-- <prompt>]
|
||||
|
|
@ -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 <tool>:<allow|deny>` 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package auggie
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
binary, err := p.ResolveBinary(ctx)
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
return auggieAccountAuthStatus(ctx, binary)
|
||||
}
|
||||
|
||||
func auggieAccountAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) {
|
||||
if binary == "" {
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := authprobe.CmdRunner(probeCtx, binary, "account", "status")
|
||||
if probeCtx.Err() != nil {
|
||||
return ports.AgentAuthStatusUnknown, probeCtx.Err()
|
||||
}
|
||||
if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown {
|
||||
return status, nil
|
||||
}
|
||||
if err == nil {
|
||||
return ports.AgentAuthStatusAuthorized, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package auggie
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestAuthStatusUsesAuggieAccountStatus(t *testing.T) {
|
||||
restore := stubAuggieAuthRunner(t, func(_ context.Context, name string, arg ...string) ([]byte, error) {
|
||||
if name != "auggie" {
|
||||
t.Fatalf("binary = %q, want auggie", name)
|
||||
}
|
||||
if !reflect.DeepEqual(arg, []string{"account", "status"}) {
|
||||
t.Fatalf("args = %#v, want [account status]", arg)
|
||||
}
|
||||
return []byte("Credits remaining: 42\n"), nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
status, err := (&Plugin{resolvedBinary: "auggie"}).AuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusUnauthorizedFromAuggieAccountStatus(t *testing.T) {
|
||||
restore := stubAuggieAuthRunner(t, func(context.Context, string, ...string) ([]byte, error) {
|
||||
return []byte("You are not currently logged in to Augment.\nRun 'auggie login' to authenticate first.\n"), errors.New("exit status 1")
|
||||
})
|
||||
defer restore()
|
||||
|
||||
status, err := (&Plugin{resolvedBinary: "auggie"}).AuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != ports.AgentAuthStatusUnauthorized {
|
||||
t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func stubAuggieAuthRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() {
|
||||
t.Helper()
|
||||
previous := authprobe.CmdRunner
|
||||
authprobe.CmdRunner = runner
|
||||
return func() { authprobe.CmdRunner = previous }
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package auggie
|
||||
|
||||
import "context"
|
||||
|
||||
// ResolveBinary resolves the executable path for the plugin.
|
||||
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
|
||||
return p.auggieBinary(ctx)
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
package authprobe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
// DefaultCommands are cheap local auth/status probes common across agent CLIs.
|
||||
// Unsupported commands usually exit quickly with help text and are treated as
|
||||
// unknown rather than unauthorized.
|
||||
var DefaultCommands = [][]string{
|
||||
{"auth", "status"},
|
||||
{"login", "status"},
|
||||
{"providers", "list"},
|
||||
}
|
||||
|
||||
// CmdRunner runs the command and returns the combined stdout/stderr.
|
||||
// It is exposed as a package variable to allow mocking in tests.
|
||||
var CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) {
|
||||
return exec.CommandContext(ctx, name, arg...).CombinedOutput()
|
||||
}
|
||||
|
||||
// CLIStatus runs bounded local CLI probes and classifies their output.
|
||||
func CLIStatus(ctx context.Context, binary string, commands [][]string) (ports.AgentAuthStatus, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if binary == "" {
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
if len(commands) == 0 {
|
||||
commands = DefaultCommands
|
||||
}
|
||||
for _, args := range commands {
|
||||
status, err := commandStatus(ctx, binary, args)
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if status != ports.AgentAuthStatusUnknown {
|
||||
return status, nil
|
||||
}
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
|
||||
func commandStatus(ctx context.Context, binary string, args []string) (ports.AgentAuthStatus, error) {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := CmdRunner(probeCtx, binary, args...)
|
||||
if probeCtx.Err() != nil {
|
||||
return ports.AgentAuthStatusUnknown, probeCtx.Err()
|
||||
}
|
||||
status := StatusFromText(string(out))
|
||||
if status != ports.AgentAuthStatusUnknown {
|
||||
return status, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
|
||||
// StatusFromText classifies common CLI auth/status output.
|
||||
func StatusFromText(out string) ports.AgentAuthStatus {
|
||||
text := strings.ToLower(out)
|
||||
compactText := compact(text)
|
||||
if hasAny(text,
|
||||
"not logged in",
|
||||
"not currently logged in",
|
||||
"logged out",
|
||||
"not authenticated",
|
||||
"unauthenticated",
|
||||
"authentication required",
|
||||
"not authorized",
|
||||
"unauthorized",
|
||||
"login required",
|
||||
"no credentials",
|
||||
"0 credentials",
|
||||
"no api key",
|
||||
"no token",
|
||||
`"loggedin": false`,
|
||||
`"loggedin":false`,
|
||||
) || hasAny(compactText,
|
||||
`"authenticated":false`,
|
||||
`'authenticated':false`,
|
||||
"authenticated:false",
|
||||
"authenticated=false",
|
||||
`"authorized":false`,
|
||||
`'authorized':false`,
|
||||
"authorized:false",
|
||||
"authorized=false",
|
||||
`"logged_in":false`,
|
||||
`'logged_in':false`,
|
||||
"logged_in:false",
|
||||
"logged_in=false",
|
||||
`"loggedin":false`,
|
||||
`'loggedin':false`,
|
||||
"loggedin:false",
|
||||
"loggedin=false",
|
||||
) {
|
||||
return ports.AgentAuthStatusUnauthorized
|
||||
}
|
||||
if hasAny(text,
|
||||
"logged in",
|
||||
"authenticated",
|
||||
"authorized",
|
||||
"token valid",
|
||||
"api key found",
|
||||
"credentials found",
|
||||
`"loggedin": true`,
|
||||
`"loggedin":true`,
|
||||
) {
|
||||
return ports.AgentAuthStatusAuthorized
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown
|
||||
}
|
||||
|
||||
func compact(text string) string {
|
||||
return strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "").Replace(text)
|
||||
}
|
||||
|
||||
func hasAny(text string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(text, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package authprobe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestCLIStatus_Mocked(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mockOutput string
|
||||
mockError error
|
||||
wantStatus ports.AgentAuthStatus
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "authorized status check",
|
||||
mockOutput: "User is logged in and authenticated",
|
||||
wantStatus: ports.AgentAuthStatusAuthorized,
|
||||
},
|
||||
{
|
||||
name: "unauthorized status check",
|
||||
mockOutput: "You are not logged in",
|
||||
wantStatus: ports.AgentAuthStatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "unknown status check with exit error",
|
||||
mockOutput: "command not found or invalid syntax",
|
||||
mockError: errors.New("exit status 1"),
|
||||
wantStatus: ports.AgentAuthStatusUnknown,
|
||||
},
|
||||
{
|
||||
name: "unknown status check with success but unrecognized output",
|
||||
mockOutput: "some random output here",
|
||||
wantStatus: ports.AgentAuthStatusUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Save and restore CmdRunner
|
||||
oldCmdRunner := CmdRunner
|
||||
defer func() { CmdRunner = oldCmdRunner }()
|
||||
|
||||
CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) {
|
||||
return []byte(tt.mockOutput), tt.mockError
|
||||
}
|
||||
|
||||
status, err := CLIStatus(context.Background(), "mockbinary", nil)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status != tt.wantStatus {
|
||||
t.Errorf("CLIStatus() = %v, want %v", status, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFromTextExplicitFalseKeys(t *testing.T) {
|
||||
tests := []string{
|
||||
`{ "authenticated": false }`,
|
||||
`{ "authorized": false }`,
|
||||
`authenticated=false`,
|
||||
`authorized: false`,
|
||||
`{ "logged_in": false }`,
|
||||
`{ "loggedIn": false }`,
|
||||
}
|
||||
|
||||
for _, out := range tests {
|
||||
t.Run(out, func(t *testing.T) {
|
||||
if got := StatusFromText(out); got != ports.AgentAuthStatusUnauthorized {
|
||||
t.Fatalf("StatusFromText(%q) = %q, want %q", out, got, ports.AgentAuthStatusUnauthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFromTextExplicitTrueKeys(t *testing.T) {
|
||||
tests := []string{
|
||||
`{ "authenticated": true }`,
|
||||
`{ "authorized": true }`,
|
||||
`{ "loggedIn": true }`,
|
||||
}
|
||||
|
||||
for _, out := range tests {
|
||||
t.Run(out, func(t *testing.T) {
|
||||
if got := StatusFromText(out); got != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("StatusFromText(%q) = %q, want %q", out, got, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package autohand
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
return autohandConfigAuthStatus(autohandConfigPath())
|
||||
}
|
||||
|
||||
func autohandConfigAuthStatus(configPath string) (ports.AgentAuthStatus, error) {
|
||||
data, err := os.ReadFile(configPath) //nolint:gosec // path is the user's own Autohand config
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
|
||||
var config map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
|
||||
authReady, authKnown := autohandCloudAuthReady(config)
|
||||
if authReady {
|
||||
return ports.AgentAuthStatusAuthorized, nil
|
||||
}
|
||||
if authKnown {
|
||||
return ports.AgentAuthStatusUnauthorized, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
|
||||
func autohandCloudAuthReady(config map[string]json.RawMessage) (ready, known bool) {
|
||||
authRaw, ok := config["auth"]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
var auth struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(authRaw, &auth); err != nil {
|
||||
return false, false
|
||||
}
|
||||
return usableSecret(auth.Token), true
|
||||
}
|
||||
|
||||
func usableSecret(value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(normalized) {
|
||||
case "api key", "apikey", "your api key", "your-api-key", "your_api_key", "token", "your token", "your-token", "your_token", "changeme", "change-me", "replace-me", "replace_me":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package autohand
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestAutohandConfigAuthStatusAuthorized(t *testing.T) {
|
||||
path := writeAutohandAuthConfig(t, `{
|
||||
"auth": {"token": "session-token", "user": {"email": "agent@example.com"}},
|
||||
"provider": "zai",
|
||||
"zai": {"apiKey": "real-provider-key", "model": "glm-5.1"}
|
||||
}`)
|
||||
|
||||
got, err := autohandConfigAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutohandConfigAuthStatusUnauthorizedWithMissingCloudToken(t *testing.T) {
|
||||
path := writeAutohandAuthConfig(t, `{
|
||||
"auth": {"token": ""},
|
||||
"provider": "zai",
|
||||
"zai": {"apiKey": "real-provider-key"}
|
||||
}`)
|
||||
|
||||
got, err := autohandConfigAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.AgentAuthStatusUnauthorized {
|
||||
t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutohandConfigAuthStatusAuthorizedWithPlaceholderProviderKey(t *testing.T) {
|
||||
path := writeAutohandAuthConfig(t, `{
|
||||
"auth": {"token": "session-token"},
|
||||
"provider": "zai",
|
||||
"zai": {"apiKey": "api key ", "model": "glm-5.1"}
|
||||
}`)
|
||||
|
||||
got, err := autohandConfigAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutohandConfigAuthStatusUnknownWhenMissing(t *testing.T) {
|
||||
got, err := autohandConfigAuthStatus(filepath.Join(t.TempDir(), "missing.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.AgentAuthStatusUnknown {
|
||||
t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAutohandAuthConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
|
@ -6,34 +6,27 @@
|
|||
// command mode (`autohand -p <prompt>` / positional prompt), native session
|
||||
// resume (`autohand resume <sessionId>`), 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 <workspace>] <sessionId>`. 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package cline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
binary, err := p.ResolveBinary(ctx)
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if status, ok, err := clineProviderAuthStatus(ctx); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
} else if ok {
|
||||
return status, nil
|
||||
}
|
||||
return authprobe.CLIStatus(ctx, binary, nil)
|
||||
}
|
||||
|
||||
type clineProvidersFile struct {
|
||||
LastUsedProvider string `json:"lastUsedProvider"`
|
||||
Providers map[string]clineProvider `json:"providers"`
|
||||
}
|
||||
|
||||
type clineProvider struct {
|
||||
Settings clineProviderSettings `json:"settings"`
|
||||
}
|
||||
|
||||
type clineProviderSettings struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
APIKeyAlt string `json:"apikey"`
|
||||
Auth *clineProviderAuth `json:"auth"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
type clineProviderAuth struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
func clineProviderAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if home == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
path := filepath.Join(home, ".cline", "data", "settings", "providers.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return ports.AgentAuthStatusUnauthorized, true, nil
|
||||
}
|
||||
|
||||
var file clineProvidersFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if len(file.Providers) == 0 {
|
||||
return ports.AgentAuthStatusUnauthorized, true, nil
|
||||
}
|
||||
|
||||
if provider, ok := configuredClineProvider(file); ok {
|
||||
if providerAuthorized(provider.Settings) {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnauthorized, true, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func configuredClineProvider(file clineProvidersFile) (clineProvider, bool) {
|
||||
if file.LastUsedProvider != "" {
|
||||
if provider, ok := file.Providers[file.LastUsedProvider]; ok {
|
||||
return provider, true
|
||||
}
|
||||
}
|
||||
for _, provider := range file.Providers {
|
||||
return provider, true
|
||||
}
|
||||
return clineProvider{}, false
|
||||
}
|
||||
|
||||
func providerAuthorized(settings clineProviderSettings) bool {
|
||||
if strings.TrimSpace(settings.APIKey) != "" || strings.TrimSpace(settings.APIKeyAlt) != "" {
|
||||
return true
|
||||
}
|
||||
if settings.Auth == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(settings.Auth.RefreshToken) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(settings.Auth.AccessToken) == "" {
|
||||
return false
|
||||
}
|
||||
if settings.Auth.ExpiresAt > 0 && settings.Auth.ExpiresAt <= time.Now().UnixMilli() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package cline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestClineProviderAuthStatusAuthorizedWithOAuth(t *testing.T) {
|
||||
writeClineProvidersFile(t, `{
|
||||
"version": 1,
|
||||
"lastUsedProvider": "cline",
|
||||
"providers": {
|
||||
"cline": {
|
||||
"settings": {
|
||||
"provider": "cline",
|
||||
"auth": {
|
||||
"accessToken": "token",
|
||||
"refreshToken": "refresh",
|
||||
"expiresAt": `+futureMillis(t)+`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
status, ok, err := clineProviderAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineProviderAuthStatusAuthorizedWithAPIKey(t *testing.T) {
|
||||
writeClineProvidersFile(t, `{
|
||||
"version": 1,
|
||||
"lastUsedProvider": "openai",
|
||||
"providers": {
|
||||
"openai": {
|
||||
"settings": {
|
||||
"provider": "openai",
|
||||
"apiKey": "sk-test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
status, ok, err := clineProviderAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineProviderAuthStatusUnauthorizedWithExpiredOAuth(t *testing.T) {
|
||||
writeClineProvidersFile(t, `{
|
||||
"version": 1,
|
||||
"lastUsedProvider": "cline",
|
||||
"providers": {
|
||||
"cline": {
|
||||
"settings": {
|
||||
"provider": "cline",
|
||||
"auth": {
|
||||
"accessToken": "token",
|
||||
"expiresAt": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
status, ok, err := clineProviderAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusUnauthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineProviderAuthStatusUnknownWhenMissing(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
status, ok, err := clineProviderAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok || status != ports.AgentAuthStatusUnknown {
|
||||
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func writeClineProvidersFile(t *testing.T, content string) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
settingsDir := filepath.Join(home, ".cline", "data", "settings")
|
||||
if err := os.MkdirAll(settingsDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(settingsDir, "providers.json"), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func futureMillis(t *testing.T) string {
|
||||
t.Helper()
|
||||
return strconv.FormatInt(time.Now().Add(time.Hour).UnixMilli(), 10)
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@
|
|||
// workspace-local Cline hooks, and reading hook-derived session info.
|
||||
//
|
||||
// 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 <agentSessionId>`. ok is false when the
|
||||
// hook-derived native session id has not landed yet, so callers can fall back
|
||||
// to fresh launch behavior.
|
||||
// `cline [approval flags] --id <agentSessionId>`. Resumes are interactive
|
||||
// because no prompt is supplied here. ok is false when the hook-derived native
|
||||
// session id has not landed yet, so callers can fall back to fresh launch
|
||||
// behavior.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
|
|
@ -120,7 +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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package cline
|
||||
|
||||
import "context"
|
||||
|
||||
// ResolveBinary resolves the executable path for the plugin.
|
||||
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
|
||||
return p.clineBinary(ctx)
|
||||
}
|
||||
|
|
@ -13,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 <agentSessionId>`. 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package codex
|
||||
|
||||
import "context"
|
||||
|
||||
// ResolveBinary resolves the executable path for the plugin.
|
||||
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
|
||||
return p.codexBinary(ctx)
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package continueagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func continueLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("CONTINUE_API_KEY")) != "" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
status, ok, err := continueConfigAuthStatus(filepath.Join(home, ".continue", "config.yaml"))
|
||||
if err != nil || ok {
|
||||
return status, ok, err
|
||||
}
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func continueConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if continueConfigHasCredential(&root) {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func continueConfigHasCredential(node *yaml.Node) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
switch node.Kind {
|
||||
case yaml.DocumentNode, yaml.SequenceNode:
|
||||
for _, child := range node.Content {
|
||||
if continueConfigHasCredential(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
key := strings.ToLower(strings.TrimSpace(node.Content[i].Value))
|
||||
value := strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`)
|
||||
if (strings.Contains(key, "apikey") || strings.Contains(key, "api_key") || strings.Contains(key, "token")) &&
|
||||
value != "" &&
|
||||
!strings.EqualFold(value, "null") &&
|
||||
!strings.EqualFold(value, "none") {
|
||||
return true
|
||||
}
|
||||
if continueConfigHasCredential(node.Content[i+1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package continueagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestContinueLocalAuthStatusAuthorizedFromEnv(t *testing.T) {
|
||||
t.Setenv("CONTINUE_API_KEY", "continue-key")
|
||||
|
||||
status, ok, err := continueLocalAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueLocalAuthStatusUnknownWithoutEnv(t *testing.T) {
|
||||
t.Setenv("CONTINUE_API_KEY", "")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
status, ok, err := continueLocalAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok || status != ports.AgentAuthStatusUnknown {
|
||||
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueConfigAuthStatusAuthorizedWithAPIKey(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("models:\n - provider: anthropic\n apiKey: continue-key\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := continueConfigAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
|
@ -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] <prompt>`.
|
||||
//
|
||||
// `--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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
package copilot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
binary, err := p.ResolveBinary(ctx)
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if status, ok, err := copilotLocalAuthStatus(ctx); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
} else if ok {
|
||||
return status, nil
|
||||
}
|
||||
return authprobe.CLIStatus(ctx, binary, nil)
|
||||
}
|
||||
|
||||
var copilotTokenEnvVars = []string{
|
||||
"COPILOT_GITHUB_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
}
|
||||
|
||||
func copilotLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
for _, name := range copilotTokenEnvVars {
|
||||
if strings.TrimSpace(os.Getenv(name)) != "" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if home == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
configStatus, configOK, err := copilotConfigAuthStatus(filepath.Join(home, ".copilot", "config.json"))
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if configOK {
|
||||
return configStatus, true, nil
|
||||
}
|
||||
if status, ok, err := copilotSessionStateAuthStatus(ctx, filepath.Join(home, ".copilot", "session-state")); err != nil || ok {
|
||||
return status, ok, err
|
||||
}
|
||||
if status, ok, err := copilotGHAuthStatus(ctx); err != nil || ok {
|
||||
return status, ok, err
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func copilotConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if text == "" {
|
||||
return ports.AgentAuthStatusUnauthorized, true, nil
|
||||
}
|
||||
if textContainsTokenAssignment(text) {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func copilotSessionStateAuthStatus(ctx context.Context, dir string) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
paths, err := filepath.Glob(filepath.Join(dir, "*", "events.jsonl"))
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
for _, path := range paths {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if copilotEventsShowModelUse(string(data)) {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func copilotEventsShowModelUse(text string) bool {
|
||||
return strings.Contains(text, `"model":`) ||
|
||||
strings.Contains(text, `"type":"tool.execution_complete"`) ||
|
||||
strings.Contains(text, `"type":"message"`)
|
||||
}
|
||||
|
||||
func copilotGHAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(probeCtx, "gh", "auth", "token").CombinedOutput()
|
||||
if probeCtx.Err() != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, probeCtx.Err()
|
||||
}
|
||||
text := strings.TrimSpace(string(out))
|
||||
if err == nil && text != "" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(text), "no oauth token") || strings.Contains(strings.ToLower(text), "not logged") {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func textContainsTokenAssignment(text string) bool {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
if !strings.Contains(lower, "token") && !strings.Contains(lower, "auth") {
|
||||
continue
|
||||
}
|
||||
for _, sep := range []string{":", "="} {
|
||||
_, after, ok := strings.Cut(line, sep)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value := strings.Trim(strings.TrimSpace(strings.TrimRight(after, ",")), `"'`)
|
||||
if value != "" && !strings.EqualFold(value, "null") && !strings.EqualFold(value, "none") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -6,9 +6,9 @@
|
|||
// "copilot", installed via npm "@github/copilot"), NOT the older `gh copilot`
|
||||
// suggest/explain extension.
|
||||
//
|
||||
// Launch runs the CLI in non-interactive ("programmatic") mode with `-p
|
||||
// <prompt>` so it executes the task and exits. Permission modes map onto the
|
||||
// CLI's allow flags (`--allow-tool`, `--allow-all-tools`, `--allow-all`).
|
||||
// Launch runs the CLI in interactive mode so AO can keep a durable terminal
|
||||
// pane attached to the session. Permission modes map onto the CLI's allow flags
|
||||
// (`--allow-tool`, `--allow-all-tools`, `--allow-all`).
|
||||
// Restore continues an existing session via `--resume <agentSessionId>`; the
|
||||
// native session id (a UUID under ~/.copilot/session-state/) is captured by the
|
||||
// SessionStart hook AO installs (see hooks.go).
|
||||
|
|
@ -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 <prompt>]
|
||||
// copilot [permission flags]
|
||||
//
|
||||
// The prompt is delivered with `-p`, which runs the prompt in non-interactive
|
||||
// mode and exits when done. Copilot CLI does not have a documented
|
||||
// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored.
|
||||
// The prompt is delivered after the process starts; using `-p` runs Copilot in
|
||||
// programmatic mode and exits when done, which leaves AO's terminal pane blank
|
||||
// or dead. Copilot CLI does not have a documented system-prompt-injection flag,
|
||||
// so SystemPrompt/SystemPromptFile are ignored.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.copilotBinary(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -90,20 +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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package copilot
|
||||
|
||||
import "context"
|
||||
|
||||
// ResolveBinary resolves the executable path for the plugin.
|
||||
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
|
||||
return p.copilotBinary(ctx)
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package crush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
binary, err := p.ResolveBinary(ctx)
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if status, ok, err := crushLocalAuthStatus(ctx); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
} else if ok {
|
||||
return status, nil
|
||||
}
|
||||
return authprobe.CLIStatus(ctx, binary, nil)
|
||||
}
|
||||
|
||||
func crushLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
dataDir, ok := crushDataDir()
|
||||
if !ok {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
return crushProvidersAuthStatus(filepath.Join(dataDir, "providers.json"))
|
||||
}
|
||||
|
||||
func crushDataDir() (string, bool) {
|
||||
if dataDir := strings.TrimSpace(os.Getenv("CRUSH_DATA_DIR")); dataDir != "" {
|
||||
return dataDir, true
|
||||
}
|
||||
if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" {
|
||||
return filepath.Join(dataHome, "crush"), true
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(home, ".local", "share", "crush"), true
|
||||
}
|
||||
|
||||
type crushProviderAuth struct {
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
func crushProvidersAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
var providers []crushProviderAuth
|
||||
if err := json.Unmarshal(data, &providers); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
for _, provider := range providers {
|
||||
if strings.TrimSpace(provider.APIKey) != "" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
}
|
||||
return ports.AgentAuthStatusUnauthorized, true, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package crush
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestCrushProvidersAuthStatusAuthorizedWithAPIKey(t *testing.T) {
|
||||
path := writeCrushProviders(t, `[{"id":"anthropic","api_key":"sk-test"}]`)
|
||||
|
||||
status, ok, err := crushProvidersAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrushProvidersAuthStatusUnauthorizedWithEmptyAPIKeys(t *testing.T) {
|
||||
path := writeCrushProviders(t, `[{"id":"anthropic","api_key":""}]`)
|
||||
|
||||
status, ok, err := crushProvidersAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusUnauthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCrushProviders(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "providers.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
|
@ -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 <WorkspacePath>] [--yolo] --session <agentSessionId>`.
|
||||
// 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
package cursor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
binary, err := p.ResolveBinary(ctx)
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if status, err := cursorCLIAuthStatus(ctx, binary); err == nil && status != ports.AgentAuthStatusUnknown {
|
||||
return status, nil
|
||||
} else if err != nil && ctx.Err() != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if status, ok, err := cursorLocalAuthStatus(ctx); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
} else if ok {
|
||||
return status, nil
|
||||
}
|
||||
return authprobe.CLIStatus(ctx, binary, nil)
|
||||
}
|
||||
|
||||
func cursorCLIAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) {
|
||||
return authprobe.CLIStatus(ctx, binary, [][]string{{"status"}})
|
||||
}
|
||||
|
||||
func cursorLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if home == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
return cursorConfigAuthStatus(filepath.Join(home, ".cursor", "cli-config.json"))
|
||||
}
|
||||
|
||||
func cursorConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
type cursorConfig struct {
|
||||
AuthInfo struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
UserID any `json:"userId"`
|
||||
AuthID string `json:"authId"`
|
||||
} `json:"authInfo"`
|
||||
}
|
||||
|
||||
var cfgs []cursorConfig
|
||||
if err := json.Unmarshal(data, &cfgs); err != nil {
|
||||
var cfg cursorConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
cfgs = []cursorConfig{cfg}
|
||||
}
|
||||
for _, cfg := range cfgs {
|
||||
if cursorConfigHasIdentity(cfg) {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func cursorConfigHasIdentity(cfg struct {
|
||||
AuthInfo struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
UserID any `json:"userId"`
|
||||
AuthID string `json:"authId"`
|
||||
} `json:"authInfo"`
|
||||
}) bool {
|
||||
if cfg.AuthInfo.UserID != nil {
|
||||
switch v := cfg.AuthInfo.UserID.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.AuthInfo.AuthID) != "" ||
|
||||
strings.TrimSpace(cfg.AuthInfo.Email) != "" ||
|
||||
strings.TrimSpace(cfg.AuthInfo.DisplayName) != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package cursor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestCursorCLIAuthStatusAuthorizedFromStatus(t *testing.T) {
|
||||
restore := stubCursorAuthCommand(t, []string{"status"}, []byte("✓ Logged in as user@example.com\n"), nil)
|
||||
defer restore()
|
||||
|
||||
status, err := cursorCLIAuthStatus(context.Background(), "cursor-agent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorCLIAuthStatusUnknownFromKeychainError(t *testing.T) {
|
||||
restore := stubCursorAuthCommand(t, []string{"status"}, []byte("ERROR: SecItemCopyMatching failed -50\n"), assertErr("exit status 139"))
|
||||
defer restore()
|
||||
|
||||
status, err := cursorCLIAuthStatus(context.Background(), "cursor-agent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != ports.AgentAuthStatusUnknown {
|
||||
t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorConfigAuthStatusAuthorizedWithAuthInfo(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cli-config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"authInfo":{"email":"user@example.com","userId":"user-1","authId":"auth-1"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := cursorConfigAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorConfigAuthStatusUnknownWithoutAuthInfo(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cli-config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"model":{"modelId":"cursor-default"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := cursorConfigAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok || status != ports.AgentAuthStatusUnknown {
|
||||
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
type assertErr string
|
||||
|
||||
func (e assertErr) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func stubCursorAuthCommand(t *testing.T, wantArgs []string, out []byte, err error) func() {
|
||||
t.Helper()
|
||||
previous := authprobe.CmdRunner
|
||||
authprobe.CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) {
|
||||
if name != "cursor-agent" || !reflect.DeepEqual(arg, wantArgs) {
|
||||
t.Fatalf("command = %s %#v, want cursor-agent %#v", name, arg, wantArgs)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
return func() { authprobe.CmdRunner = previous }
|
||||
}
|
||||
|
||||
func TestCursorConfigAuthStatusUnknownWhenMissing(t *testing.T) {
|
||||
status, ok, err := cursorConfigAuthStatus(filepath.Join(t.TempDir(), "missing.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok || status != ports.AgentAuthStatusUnknown {
|
||||
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
|
||||
}
|
||||
}
|
||||
|
|
@ -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] <prompt>
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package cursor
|
||||
|
||||
import "context"
|
||||
|
||||
// ResolveBinary resolves the executable path for the plugin.
|
||||
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
|
||||
return p.cursorBinary(ctx)
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package devin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
binary, err := p.ResolveBinary(ctx)
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if status, ok, err := devinLocalAuthStatus(ctx); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
} else if ok {
|
||||
return status, nil
|
||||
}
|
||||
return authprobe.CLIStatus(ctx, binary, [][]string{{"auth", "status"}})
|
||||
}
|
||||
|
||||
func devinLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if home == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
return devinCredentialsAuthStatus(filepath.Join(home, ".local", "share", "devin", "credentials.toml"))
|
||||
}
|
||||
|
||||
func devinCredentialsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if text == "" {
|
||||
return ports.AgentAuthStatusUnauthorized, true, nil
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
if strings.Contains(lower, "windsurf_api_key") ||
|
||||
strings.Contains(lower, "devin_api_url") ||
|
||||
strings.Contains(lower, "devin_webapp_host") {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package devin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestAuthStatusAuthorizedFromAuthStatusOutput(t *testing.T) {
|
||||
previous := authprobe.CmdRunner
|
||||
authprobe.CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) {
|
||||
if name != "devin" || !reflect.DeepEqual(arg, []string{"auth", "status"}) {
|
||||
t.Fatalf("command = %s %#v, want devin auth status", name, arg)
|
||||
}
|
||||
return []byte("Logged in (via Devin).\n\nUser:\n Email: agentsubs@example.com\n"), nil
|
||||
}
|
||||
defer func() { authprobe.CmdRunner = previous }()
|
||||
|
||||
got, err := (&Plugin{resolvedBinary: "devin"}).AuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevinCredentialsAuthStatusAuthorized(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "credentials.toml")
|
||||
if err := os.WriteFile(path, []byte("windsurf_api_key = \"token\"\ndevin_api_url = \"https://api.devin.ai\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := devinCredentialsAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevinCredentialsAuthStatusUnauthorizedWithEmptyFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "credentials.toml")
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := devinCredentialsAuthStatus(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusUnauthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <mode>] -p <prompt>`.
|
||||
// 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package devin
|
||||
|
||||
import "context"
|
||||
|
||||
// ResolveBinary resolves the executable path for the plugin.
|
||||
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
|
||||
return p.devinBinary(ctx)
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package droid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
if _, err := p.ResolveBinary(ctx); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
status, ok, err := droidLocalAuthStatus(ctx)
|
||||
if err != nil || ok {
|
||||
return status, err
|
||||
}
|
||||
return ports.AgentAuthStatusUnauthorized, nil
|
||||
}
|
||||
|
||||
func droidLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("FACTORY_API_KEY")) != "" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if home == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
return droidFactoryAuthStatus(filepath.Join(home, ".factory"))
|
||||
}
|
||||
|
||||
func droidFactoryAuthStatus(factoryDir string) (ports.AgentAuthStatus, bool, error) {
|
||||
if factoryDir == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
if fileHasContent(filepath.Join(factoryDir, "auth.v2.file")) && fileHasContent(filepath.Join(factoryDir, "auth.v2.key")) {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
return droidSettingsAuthStatus(filepath.Join(factoryDir, "settings.json"))
|
||||
}
|
||||
|
||||
func droidSettingsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
var settings struct {
|
||||
CustomModels []struct {
|
||||
Model string `json:"model"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
} `json:"customModels"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
for _, model := range settings.CustomModels {
|
||||
if strings.TrimSpace(model.Model) != "" &&
|
||||
strings.TrimSpace(model.BaseURL) != "" &&
|
||||
strings.TrimSpace(model.APIKey) != "" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
}
|
||||
if len(settings.CustomModels) > 0 {
|
||||
return ports.AgentAuthStatusUnauthorized, true, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func fileHasContent(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir() && info.Size() > 0
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package droid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestAuthStatusAuthorizedFromFactoryAPIKey(t *testing.T) {
|
||||
t.Setenv("FACTORY_API_KEY", "fk-test")
|
||||
|
||||
got, err := (&Plugin{resolvedBinary: "droid"}).AuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroidFactoryAuthStatusAuthorizedFromAuthFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "auth.v2.file"), []byte("encrypted auth"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "auth.v2.key"), []byte("key"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := droidFactoryAuthStatus(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroidFactoryAuthStatusAuthorizedFromCustomModelAPIKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
settings := `{"customModels":[{"model":"claude-sonnet-4-5-20250929","baseUrl":"https://api.anthropic.com","apiKey":"sk-test"}],"model":"custom:Sonnet-0"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(settings), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := droidFactoryAuthStatus(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroidFactoryAuthStatusUnauthorizedFromCustomModelWithoutAPIKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
settings := `{"customModels":[{"model":"claude-sonnet-4-5-20250929","baseUrl":"https://api.anthropic.com","apiKey":""}]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(settings), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, ok, err := droidFactoryAuthStatus(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusUnauthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <path>] [--append-system-prompt[-file] <x>] [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 <path>] -r <agentSessionId>`. 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 <event>` 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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=<mode>] goose run [--system <text>] [-t <prompt>]
|
||||
// [env GOOSE_MODE=<mode>] goose run [--system <text>] -t <prompt>
|
||||
//
|
||||
// The prompt is delivered in-command via `-t`. A non-default permission mode is
|
||||
// rendered as an `env GOOSE_MODE=<mode>` prefix because Goose reads its approval
|
||||
// mode from the environment, not from a flag. System instructions, when present,
|
||||
// are passed via `--system`.
|
||||
// are passed via `--system`. Goose requires one of --instructions, --text, or
|
||||
// --recipe even when AO intentionally starts a promptless orchestrator, so empty
|
||||
// prompts are delivered as `-t "" --interactive` to land in an input-ready
|
||||
// terminal without inventing an initial task.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.gooseBinary(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -106,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=<mode>] goose run --resume --session-id <agentSessionId>
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package goose
|
||||
|
||||
import "context"
|
||||
|
||||
// ResolveBinary resolves the executable path for the plugin.
|
||||
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
|
||||
return p.gooseBinary(ctx)
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package grok
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
binary, err := p.ResolveBinary(ctx)
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if status, ok, err := grokLocalAuthStatus(ctx); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
} else if ok {
|
||||
return status, nil
|
||||
}
|
||||
return authprobe.CLIStatus(ctx, binary, nil)
|
||||
}
|
||||
|
||||
func grokLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("GROK_API_KEY")) != "" || strings.TrimSpace(os.Getenv("XAI_API_KEY")) != "" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if home == "" {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
path := filepath.Join(home, ".grok", "auth.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return ports.AgentAuthStatusUnauthorized, true, nil
|
||||
}
|
||||
|
||||
var entries map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return ports.AgentAuthStatusUnauthorized, true, nil
|
||||
}
|
||||
for key, value := range entries {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(value))
|
||||
if trimmed != "" && trimmed != "null" && trimmed != "{}" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
}
|
||||
return ports.AgentAuthStatusUnauthorized, true, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package grok
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestGrokLocalAuthStatusAuthorizedWithAPIKeyEnv(t *testing.T) {
|
||||
t.Setenv("XAI_API_KEY", "xai-test")
|
||||
|
||||
status, ok, err := grokLocalAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokLocalAuthStatusAuthorizedWithAuthFile(t *testing.T) {
|
||||
writeGrokAuthFile(t, `{
|
||||
"https://auth.x.ai::account": {
|
||||
"access_token": "token",
|
||||
"refresh_token": "refresh"
|
||||
}
|
||||
}`)
|
||||
|
||||
status, ok, err := grokLocalAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusAuthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokLocalAuthStatusUnauthorizedWithEmptyAuthFile(t *testing.T) {
|
||||
writeGrokAuthFile(t, `{}`)
|
||||
|
||||
status, ok, err := grokLocalAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || status != ports.AgentAuthStatusUnauthorized {
|
||||
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokLocalAuthStatusUnknownWhenMissing(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
status, ok, err := grokLocalAuthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok || status != ports.AgentAuthStatusUnknown {
|
||||
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func writeGrokAuthFile(t *testing.T, content string) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
grokDir := filepath.Join(home, ".grok")
|
||||
if err := os.MkdirAll(grokDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(grokDir, "auth.json"), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <mode>] -p <prompt>`.
|
||||
// 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package grok
|
||||
|
||||
import "context"
|
||||
|
||||
// ResolveBinary resolves the executable path for the plugin.
|
||||
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
|
||||
return p.grokBinary(ctx)
|
||||
}
|
||||
|
|
@ -0,0 +1,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 "<label>.GetAgentHooks: ...".
|
||||
Label string
|
||||
// CommandPrefix identifies AO-owned hook commands, e.g. "ao hooks goose ".
|
||||
// Install skips commands already present and uninstall/detect match on it.
|
||||
CommandPrefix string
|
||||
// Timeout is written into each installed hook entry.
|
||||
Timeout int
|
||||
// Path returns the hooks file path for a workspace.
|
||||
Path func(workspacePath string) string
|
||||
// Managed is the set of hooks AO installs.
|
||||
Managed []HookSpec
|
||||
}
|
||||
|
||||
// Install merges AO's managed hooks into the workspace's hooks file, preserving
|
||||
// user-defined hooks and unrelated settings, and is idempotent (a command
|
||||
// already present is not appended). It also writes a self-ignoring .gitignore
|
||||
// covering the hooks file so it does not block worktree teardown.
|
||||
func (m Manager) Install(ctx context.Context, workspacePath string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return fmt.Errorf("%s.GetAgentHooks: WorkspacePath is required", m.Label)
|
||||
}
|
||||
|
||||
hooksPath := m.Path(workspacePath)
|
||||
topLevel, rawHooks, err := readHooksFile(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
||||
}
|
||||
|
||||
for event, specs := range m.groupByEvent() {
|
||||
var groups []MatcherGroup
|
||||
if err := parseEvent(rawHooks, event, &groups); err != nil {
|
||||
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
||||
}
|
||||
for _, spec := range specs {
|
||||
if !commandExists(groups, spec.Command) {
|
||||
entry := HookEntry{Type: "command", Command: spec.Command, Timeout: m.Timeout}
|
||||
groups = addHook(groups, entry, spec.Matcher)
|
||||
}
|
||||
}
|
||||
if err := marshalEvent(rawHooks, event, groups); err != nil {
|
||||
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeHooksFile(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), filepath.Base(hooksPath)); err != nil {
|
||||
return fmt.Errorf("%s.GetAgentHooks: gitignore: %w", m.Label, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Uninstall removes AO's hooks from the workspace's hooks file, leaving
|
||||
// user-defined hooks and unrelated settings untouched. A missing file is a no-op.
|
||||
func (m Manager) Uninstall(ctx context.Context, workspacePath string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return fmt.Errorf("%s.UninstallHooks: workspacePath is required", m.Label)
|
||||
}
|
||||
|
||||
hooksPath := m.Path(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
topLevel, rawHooks, err := readHooksFile(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
||||
}
|
||||
|
||||
for _, event := range m.managedEvents() {
|
||||
var groups []MatcherGroup
|
||||
if err := parseEvent(rawHooks, event, &groups); err != nil {
|
||||
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
||||
}
|
||||
groups = removeManaged(groups, m.CommandPrefix)
|
||||
if err := marshalEvent(rawHooks, event, groups); err != nil {
|
||||
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeHooksFile(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreInstalled reports whether any AO hook is present in the workspace's hooks
|
||||
// file. A missing file means none are installed.
|
||||
func (m Manager) AreInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return false, fmt.Errorf("%s.AreHooksInstalled: workspacePath is required", m.Label)
|
||||
}
|
||||
|
||||
hooksPath := m.Path(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
_, rawHooks, err := readHooksFile(hooksPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s.AreHooksInstalled: %w", m.Label, err)
|
||||
}
|
||||
|
||||
for _, event := range m.managedEvents() {
|
||||
var groups []MatcherGroup
|
||||
if err := parseEvent(rawHooks, event, &groups); err != nil {
|
||||
return false, fmt.Errorf("%s.AreHooksInstalled: %w", m.Label, err)
|
||||
}
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if strings.HasPrefix(hook.Command, m.CommandPrefix) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// groupByEvent groups the managed specs by event so each event array is
|
||||
// rewritten once.
|
||||
func (m Manager) groupByEvent() map[string][]HookSpec {
|
||||
byEvent := map[string][]HookSpec{}
|
||||
for _, spec := range m.Managed {
|
||||
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
|
||||
}
|
||||
return byEvent
|
||||
}
|
||||
|
||||
// managedEvents returns the distinct managed events, in first-seen order.
|
||||
func (m Manager) managedEvents() []string {
|
||||
seen := map[string]bool{}
|
||||
events := make([]string, 0, len(m.Managed))
|
||||
for _, spec := range m.Managed {
|
||||
if !seen[spec.Event] {
|
||||
seen[spec.Event] = true
|
||||
events = append(events, spec.Event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// readHooksFile loads the 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 readHooksFile(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
|
||||
}
|
||||
|
||||
// writeHooksFile folds rawHooks back into topLevel and writes the file
|
||||
// atomically. An empty hooks map drops the "hooks" key entirely.
|
||||
func writeHooksFile(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 := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", hooksPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseEvent(rawHooks map[string]json.RawMessage, event string, target *[]MatcherGroup) 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 marshalEvent(rawHooks map[string]json.RawMessage, event string, groups []MatcherGroup) 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 commandExists(groups []MatcherGroup, command string) bool {
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if hook.Command == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// addHook appends hook to the group with a matching matcher, creating that group
|
||||
// if none matches.
|
||||
func addHook(groups []MatcherGroup, hook HookEntry, matcher *string) []MatcherGroup {
|
||||
for i, group := range groups {
|
||||
if matchersEqual(group.Matcher, matcher) {
|
||||
groups[i].Hooks = append(groups[i].Hooks, hook)
|
||||
return groups
|
||||
}
|
||||
}
|
||||
return append(groups, MatcherGroup{Matcher: matcher, Hooks: []HookEntry{hook}})
|
||||
}
|
||||
|
||||
// removeManaged strips AO hook entries (matched by command prefix) from every
|
||||
// group, dropping any group left without hooks so the event array doesn't
|
||||
// accumulate empty matcher objects.
|
||||
func removeManaged(groups []MatcherGroup, prefix string) []MatcherGroup {
|
||||
result := make([]MatcherGroup, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
kept := make([]HookEntry, 0, len(group.Hooks))
|
||||
for _, hook := range group.Hooks {
|
||||
if !strings.HasPrefix(hook.Command, prefix) {
|
||||
kept = append(kept, hook)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
group.Hooks = kept
|
||||
result = append(result, group)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func matchersEqual(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
|
@ -53,6 +53,14 @@ func EnsureWorkspaceGitignore(dir string, names ...string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// FileExists reports whether path names an existing regular file (not a
|
||||
// directory). Adapters use it when probing well-known install locations for an
|
||||
// agent binary.
|
||||
func FileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// 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 the agent then fails to parse (silently disabling hooks).
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
package kilocode
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// DeriveActivityState maps a Kilo Code plugin 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 the installed plugin shells via
|
||||
// `ao hooks kilocode <event>` (see kilocodeManagedEvents in hooks.go), not a
|
||||
// native Kilo event name. The plugin reports:
|
||||
// - "session-start" → a Kilo session was created (turn begins).
|
||||
// - "user-prompt-submit" → the user submitted a prompt (turn begins).
|
||||
// - "permission-request" → Kilo is asking the user to approve a tool call.
|
||||
// - "stop" → the current turn went idle/finished.
|
||||
//
|
||||
// Kilo has no native session/process-end plugin event the adapter maps to
|
||||
// ActivityExited, 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
package kilocode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
|
||||
_ "modernc.org/sqlite" // register sqlite driver for KiloCode auth database probes
|
||||
)
|
||||
|
||||
var _ ports.AgentAuthChecker = (*Plugin)(nil)
|
||||
|
||||
// AuthStatus returns the plugin's local authentication status.
|
||||
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
|
||||
binary, err := p.ResolveBinary(ctx)
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
}
|
||||
if status, ok, err := kilocodeLocalAuthStatus(ctx); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, err
|
||||
} else if ok {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(probeCtx, binary, "auth", "list").CombinedOutput()
|
||||
if probeCtx.Err() != nil {
|
||||
return ports.AgentAuthStatusUnknown, probeCtx.Err()
|
||||
}
|
||||
status, ok := kilocodeAuthListStatus(string(out))
|
||||
if ok {
|
||||
return status, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, nil
|
||||
}
|
||||
|
||||
var kilocodeAPIKeyEnvVars = []string{
|
||||
"KILO_API_KEY",
|
||||
"KILOCODE_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"GROQ_API_KEY",
|
||||
"XAI_API_KEY",
|
||||
"MISTRAL_API_KEY",
|
||||
"COHERE_API_KEY",
|
||||
}
|
||||
|
||||
func kilocodeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
for _, name := range kilocodeAPIKeyEnvVars {
|
||||
if strings.TrimSpace(os.Getenv(name)) != "" {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
}
|
||||
dataDir, ok := kilocodeDataDir()
|
||||
if !ok {
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
authorized, known, err := kilocodeAuthJSONStatus(filepath.Join(dataDir, "auth.json"))
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if known && authorized {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
authorized, known, err = kilocodeDBAuthStatus(ctx, filepath.Join(dataDir, "kilo.db"))
|
||||
if err != nil {
|
||||
return ports.AgentAuthStatusUnknown, false, err
|
||||
}
|
||||
if known && authorized {
|
||||
return ports.AgentAuthStatusAuthorized, true, nil
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false, nil
|
||||
}
|
||||
|
||||
func kilocodeDataDir() (string, bool) {
|
||||
if dataDir := strings.TrimSpace(os.Getenv("KILO_DATA_DIR")); dataDir != "" {
|
||||
return dataDir, true
|
||||
}
|
||||
if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" {
|
||||
return filepath.Join(dataHome, "kilo"), true
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(home, ".local", "share", "kilo"), true
|
||||
}
|
||||
|
||||
func kilocodeAuthJSONStatus(path string) (authorized, known bool, err error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return false, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return false, false, nil
|
||||
}
|
||||
var providers map[string]map[string]any
|
||||
if err := json.Unmarshal(data, &providers); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
for _, provider := range providers {
|
||||
if len(provider) == 0 {
|
||||
continue
|
||||
}
|
||||
known = true
|
||||
for _, key := range []string{"key", "apiKey", "api_key", "access_token", "token"} {
|
||||
if strings.TrimSpace(asString(provider[key])) != "" {
|
||||
return true, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, known, nil
|
||||
}
|
||||
|
||||
func asString(value any) string {
|
||||
s, _ := value.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func kilocodeDBAuthStatus(ctx context.Context, path string) (authorized, known bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return false, false, nil
|
||||
} else if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro&_pragma=busy_timeout(1000)")
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
defer func() {
|
||||
_ = db.Close()
|
||||
}()
|
||||
return kilocodeDBHasAuthorizedAccount(ctx, db)
|
||||
}
|
||||
|
||||
func kilocodeDBHasAuthorizedAccount(ctx context.Context, db *sql.DB) (authorized, known bool, err error) {
|
||||
for _, query := range []string{
|
||||
`SELECT COUNT(*) FROM account_state WHERE active_account_id IS NOT NULL AND trim(active_account_id) != ''`,
|
||||
`SELECT COUNT(*) FROM account WHERE trim(access_token) != ''`,
|
||||
`SELECT COUNT(*) FROM control_account WHERE active = 1 AND trim(access_token) != ''`,
|
||||
} {
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, query).Scan(&count); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "no such table") {
|
||||
continue
|
||||
}
|
||||
return false, false, err
|
||||
}
|
||||
known = true
|
||||
if count > 0 {
|
||||
return true, true, nil
|
||||
}
|
||||
}
|
||||
return false, known, nil
|
||||
}
|
||||
|
||||
var kilocodeAuthListCountRE = regexp.MustCompile(`(?m)\b([1-9][0-9]*)\s+(credentials?|environment variables?)\b`)
|
||||
|
||||
func kilocodeAuthListStatus(output string) (ports.AgentAuthStatus, bool) {
|
||||
text := strings.ToLower(output)
|
||||
if kilocodeAuthListCountRE.MatchString(text) {
|
||||
return ports.AgentAuthStatusAuthorized, true
|
||||
}
|
||||
if strings.Contains(text, "0 credentials") && strings.Contains(text, "0 environment variable") {
|
||||
return ports.AgentAuthStatusUnauthorized, true
|
||||
}
|
||||
return ports.AgentAuthStatusUnknown, false
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue