From a32d507eb8b9f9e99bc9d3e94d2d7c3977d66821 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Fri, 3 Jul 2026 20:50:41 +0530 Subject: [PATCH 01/15] feat(scm): detect cross-fork PRs by scanning all project remotes (#2368) The SCM observer only polled a project's push origin for open PRs, so a PR opened from the fork against an upstream base repo (the standard fork -> upstream flow) was never discovered: GitHub lists a PR under its base repo, not its head, so an origin-only scan can't see it. Sessions that opened such PRs showed no PR on the dashboard. Scan every GitHub remote in the project checkout (origin + upstreams / mirrors) for open PRs, and attribute a PR to a session only when its head branch lives in that session's push origin. This surfaces cross-fork PRs while preserving the no-misattribution guarantee: a stranger's fork PR has a head repo no session owns and is dropped. Tracked cross-fork PRs are keyed for refresh by their own recorded repo (the upstream base) so the GraphQL refetch targets the right repo. --- backend/internal/observe/scm/observer.go | 142 +++++++++++++++--- backend/internal/observe/scm/observer_test.go | 118 ++++++++++++++- 2 files changed, 240 insertions(+), 20 deletions(-) diff --git a/backend/internal/observe/scm/observer.go b/backend/internal/observe/scm/observer.go index 1ca0fa10e..780e669fc 100644 --- a/backend/internal/observe/scm/observer.go +++ b/backend/internal/observe/scm/observer.go @@ -191,12 +191,18 @@ type subject struct { hasPR bool } -// sessionRepo pairs a live session with its parsed repo and branch for per-repo -// branch-prefix discovery of new (including stacked) pull requests. +// sessionRepo pairs a live session with a repo to scan and its branch for +// per-repo branch-prefix discovery of new (including stacked) pull requests. +// A session is scanned against its push origin plus every other remote in the +// project checkout, so repo is the repo whose open-PR list is listed while +// headRepo is the repo the session's head branch actually lives in (the push +// origin). For same-repo PRs repo == headRepo; for a cross-fork PR (fork head, +// upstream base) repo is the upstream base and headRepo is the fork origin. type sessionRepo struct { - session domain.SessionRecord - repo ports.SCMRepo - branch string + session domain.SessionRecord + repo ports.SCMRepo + headRepo ports.SCMRepo + branch string } type repoGuardState struct { @@ -424,6 +430,8 @@ func (o *Observer) discoverSubjects(ctx context.Context) (map[string]*subject, [ return nil, nil, err } projects := map[domain.ProjectID]domain.ProjectRecord{} + originRepos := map[domain.ProjectID]ports.SCMRepo{} + scanRepos := map[domain.ProjectID][]ports.SCMRepo{} out := map[string]*subject{} var sessionRepos []sessionRepo for _, sess := range sessions { @@ -453,29 +461,87 @@ func (o *Observer) discoverSubjects(ctx context.Context) (map[string]*subject, [ } projects[sess.ProjectID] = p proj = p + if origin, ok := o.provider.ParseRepository(p.RepoOriginURL); ok { + originRepos[sess.ProjectID] = origin + scanRepos[sess.ProjectID] = o.resolveScanRepos(p, origin) + } } - repo, ok := o.provider.ParseRepository(proj.RepoOriginURL) + origin, ok := originRepos[sess.ProjectID] if !ok { o.logger.Debug("scm observer: project has no supported SCM origin", "project", proj.ID, "origin", proj.RepoOriginURL) continue } - sessionRepos = append(sessionRepos, sessionRepo{session: sess, repo: repo, branch: branch}) + for _, repo := range scanRepos[sess.ProjectID] { + sessionRepos = append(sessionRepos, sessionRepo{session: sess, repo: repo, headRepo: origin, branch: branch}) + } prs, err := o.store.ListPRsBySession(ctx, sess.ID) if err != nil { return nil, nil, err } for _, pr := range openTrackedPRs(prs) { - key := prKey(repo, pr.Number) + // A tracked PR may live on an upstream repo (cross-fork), so its + // refresh subject is keyed by the PR's own recorded repo, not the + // push origin, or the GraphQL refetch would target the wrong repo. + prRepo := subjectRepoForPR(pr, origin) + key := prKey(prRepo, pr.Number) if existing, ok := out[key]; ok { o.logger.Warn("scm observer: duplicate tracked PR ownership skipped", "pr", key, "kept_session", existing.session.ID, "skipped_session", sess.ID) continue } - out[key] = &subject{session: sess, repo: repo, branch: branch, known: pr, hasPR: true} + out[key] = &subject{session: sess, repo: prRepo, branch: branch, known: pr, hasPR: true} } } return out, sessionRepos, nil } +// resolveScanRepos returns the deduped set of repos whose open-PR lists should be +// scanned to attribute PRs to this project's sessions: the push origin plus every +// other GitHub remote configured in the project checkout (upstreams, mirrors). +// Attribution still requires a PR's head branch to live in the origin, so scanning +// extra remotes only surfaces cross-fork PRs (fork head, upstream base) and can +// never misattribute a stranger's PR. +// +// ponytail: remotes are read once per project per process (memoized by the +// caller); a remote added after the daemon started is picked up on restart. Move +// to a git-config watch if that latency ever matters. +func (o *Observer) resolveScanRepos(proj domain.ProjectRecord, origin ports.SCMRepo) []ports.SCMRepo { + repos := []ports.SCMRepo{origin} + if strings.TrimSpace(proj.Path) == "" { + return repos + } + seen := map[string]bool{prKey(origin, 0): true} + for _, url := range gitRemoteURLs(proj.Path) { + repo, ok := o.provider.ParseRepository(url) + if !ok { + continue + } + key := prKey(repo, 0) + if seen[key] { + continue + } + seen[key] = true + repos = append(repos, repo) + } + return repos +} + +// subjectRepoForPR resolves the repo that owns a tracked PR's number for refresh. +// A cross-fork PR lives on the base/upstream repo recorded in pr.Repo rather than +// the push origin, so the refresh/GraphQL fetch must target pr.Repo. Legacy rows +// written before pr.Repo was populated fall back to the origin. +func subjectRepoForPR(pr domain.PullRequest, origin ports.SCMRepo) ports.SCMRepo { + if strings.TrimSpace(pr.Repo) == "" { + return origin + } + return ports.SCMRepo{ + Provider: firstNonEmpty(pr.Provider, origin.Provider), + Host: firstNonEmpty(pr.Host, origin.Host), + Repo: pr.Repo, + Owner: ownerOf(pr.Repo), + Name: nameOf(pr.Repo), + } +} + func openTrackedPRs(prs []domain.PullRequest) []domain.PullRequest { out := make([]domain.PullRequest, 0, len(prs)) for _, pr := range prs { @@ -550,20 +616,19 @@ func (o *Observer) discoverNewPRs(ctx context.Context, sessionRepos []sessionRep if pr.Number <= 0 || pr.SourceBranch == "" { continue } - // Branch-prefix attribution must only claim PRs whose head branch - // lives in the project repo. A fork PR can carry a head branch whose - // name matches an AO session branch; its commits live in the fork, so - // auto-claiming it would misattribute work. Same-repo PRs always - // report the base repo's full name as their head repo, so anything - // else (including an empty head repo from a deleted fork) is skipped. - if !strings.EqualFold(pr.HeadRepo, repoFullName(repo)) { - continue - } key := prKey(repo, pr.Number) if _, ok := subjects[key]; ok { continue } - sr, ok := matchSession(byRepo[repoKey], pr.SourceBranch) + // Branch-prefix attribution must only claim PRs whose head branch + // lives in a session's push origin. A same-repo PR has head == origin + // == this scanned repo; a cross-fork PR (fork head, upstream base) has + // head == origin while this scanned repo is the upstream base. A + // stranger's fork PR carries a head repo no session owns and is + // dropped (as is an empty head repo from a deleted fork), preserving + // the no-misattribution guarantee. + eligible := candidatesForHeadRepo(byRepo[repoKey], pr.HeadRepo) + sr, ok := matchSession(eligible, pr.SourceBranch) if !ok { continue } @@ -612,6 +677,24 @@ func (o *Observer) discoverNewPRs(ctx context.Context, sessionRepos []sessionRep // branches are prefixes of the same source branch the longest (most specific) // one wins, so a child session claims its own stacked PRs rather than the // ancestor session. +// candidatesForHeadRepo narrows the scanned repo's session candidates to those +// whose head branch lives in headRepo (the PR's head repository full name). This +// is the fork guard: a PR is only attributable when its head repo equals a +// session's push origin, whether the PR was found on the origin itself or on a +// scanned upstream base repo. +func candidatesForHeadRepo(candidates []sessionRepo, headRepo string) []sessionRepo { + if strings.TrimSpace(headRepo) == "" { + return nil + } + var out []sessionRepo + for _, sr := range candidates { + if strings.EqualFold(repoFullName(sr.headRepo), headRepo) { + out = append(out, sr) + } + } + return out +} + func matchSession(candidates []sessionRepo, sourceBranch string) (sessionRepo, bool) { var best sessionRepo bestLen := -1 @@ -1232,6 +1315,27 @@ func resolveGitOriginURL(path string) string { return strings.TrimSpace(string(out)) } +// gitRemoteURLs lists the fetch URL of every git remote configured at path. It +// returns nil on any error (missing repo, no git, no remotes). The observer uses +// it to scan upstream/mirror remotes for cross-fork PRs in addition to origin. +func gitRemoteURLs(path string) []string { + out, err := aoprocess.Command("git", "-C", path, "remote").Output() + if err != nil { + return nil + } + var urls []string + for _, name := range strings.Fields(string(out)) { + u, err := aoprocess.Command("git", "-C", path, "remote", "get-url", name).Output() + if err != nil { + continue + } + if s := strings.TrimSpace(string(u)); s != "" { + urls = append(urls, s) + } + } + return urls +} + func scrubLine(s string) string { s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\r", " ") diff --git a/backend/internal/observe/scm/observer_test.go b/backend/internal/observe/scm/observer_test.go index dec26750a..f07b1fef5 100644 --- a/backend/internal/observe/scm/observer_test.go +++ b/backend/internal/observe/scm/observer_test.go @@ -139,7 +139,18 @@ func (p *fakeProvider) SCMCredentialsAvailable(context.Context) (bool, error) { } func (p *fakeProvider) ParseRepository(remote string) (ports.SCMRepo, bool) { - return testRepo, remote != "" + remote = strings.TrimSpace(remote) + if remote == "" { + return ports.SCMRepo{}, false + } + s := strings.TrimSuffix(remote, ".git") + s = strings.TrimPrefix(s, "https://github.com/") + s = strings.TrimPrefix(s, "git@github.com:") + parts := strings.Split(strings.Trim(s, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return ports.SCMRepo{}, false + } + return ports.SCMRepo{Provider: "github", Host: "github.com", Owner: parts[0], Name: parts[1], Repo: parts[0] + "/" + parts[1]}, true } func (p *fakeProvider) RepoPRListGuard(_ context.Context, repo ports.SCMRepo, _ string) (ports.SCMGuardResult, error) { p.mu.Lock() @@ -540,6 +551,111 @@ func TestPoll_IgnoresForkPRWithMatchingBranch(t *testing.T) { } } +func mustGit(t *testing.T, args ...string) { + t.Helper() + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v (%s)", args, err, out) + } +} + +// A PR opened from the fork push origin against an upstream base repo (the +// standard fork -> upstream contribution flow) must be discovered and attributed +// by scanning every remote in the project checkout, not just origin. Its head +// lives in origin (o/r) while its base lives in upstream (up/r); the persisted +// row records the upstream base repo so refresh refetches against it. +func TestPoll_DiscoversCrossForkPRFromUpstreamRemote(t *testing.T) { + dir := t.TempDir() + mustGit(t, "init", dir) + mustGit(t, "-C", dir, "remote", "add", "origin", "https://github.com/o/r.git") + mustGit(t, "-C", dir, "remote", "add", "upstream", "https://github.com/up/r.git") + + upstream := ports.SCMRepo{Provider: "github", Host: "github.com", Owner: "up", Name: "r", Repo: "up/r"} + store := &fakeStore{ + sessions: []domain.SessionRecord{{ID: "p-1", ProjectID: "p", Metadata: domain.SessionMetadata{Branch: "ao/p-1/root"}}}, + projects: map[string]domain.ProjectRecord{"p": {ID: "p", Path: dir, RepoOriginURL: "https://github.com/o/r.git"}}, + prs: map[domain.SessionID][]domain.PullRequest{}, + checks: map[string][]domain.PullRequestCheck{}, + } + crossObs := ports.SCMObservation{ + Fetched: true, Provider: "github", Host: "github.com", Repo: "up/r", + PR: ports.SCMPRObservation{URL: "https://github.com/up/r/pull/1", Number: 1, State: "open", SourceBranch: "ao/p-1/feat", HeadRepo: "o/r", TargetBranch: "main", HeadSHA: "sha1", Title: "PR"}, + CI: ports.SCMCIObservation{Summary: string(domain.CIPassing), HeadSHA: "sha1"}, + Review: ports.SCMReviewObservation{Decision: string(domain.ReviewNone)}, + Mergeability: ports.SCMMergeabilityObservation{State: string(domain.MergeMergeable), Mergeable: true}, + } + provider := &fakeProvider{ + repoGuards: map[string]ports.SCMGuardResult{prKey(testRepo, 0): {ETag: "origin"}, prKey(upstream, 0): {ETag: "up"}}, + openPRs: map[string][]ports.SCMPRObservation{ + prKey(upstream, 0): {{URL: "https://github.com/up/r/pull/1", Number: 1, SourceBranch: "ao/p-1/feat", HeadRepo: "o/r", TargetBranch: "main", HeadSHA: "sha1"}}, + }, + observations: map[string]ports.SCMObservation{prKey(upstream, 1): crossObs}, + } + lc := &fakeLifecycle{} + obs := newTestObserver(store, provider, lc, time.Unix(1, 0).UTC()) + if err := obs.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if len(store.writes) == 0 { + t.Fatal("expected cross-fork PR write") + } + got := store.writes[0].pr + if got.SessionID != "p-1" { + t.Fatalf("session id = %q, want p-1", got.SessionID) + } + if got.Repo != "up/r" { + t.Fatalf("pr repo = %q, want up/r (upstream base)", got.Repo) + } + if got.SourceBranch != "ao/p-1/feat" { + t.Fatalf("source branch = %q, want ao/p-1/feat", got.SourceBranch) + } + fetched := false + for _, batch := range provider.fetchBatches { + for _, ref := range batch { + if ref.Repo.Repo == "up/r" && ref.Number == 1 { + fetched = true + } + } + } + if !fetched { + t.Fatalf("cross-fork PR must be refreshed against upstream, batches=%#v", provider.fetchBatches) + } +} + +// A PR on a scanned upstream remote whose head lives in some third-party fork +// (not this project's origin) must never be attributed, even though its branch +// name matches a session. Scanning extra remotes stays safe. +func TestPoll_IgnoresUpstreamPRFromForeignHead(t *testing.T) { + dir := t.TempDir() + mustGit(t, "init", dir) + mustGit(t, "-C", dir, "remote", "add", "origin", "https://github.com/o/r.git") + mustGit(t, "-C", dir, "remote", "add", "upstream", "https://github.com/up/r.git") + + upstream := ports.SCMRepo{Provider: "github", Host: "github.com", Owner: "up", Name: "r", Repo: "up/r"} + store := &fakeStore{ + sessions: []domain.SessionRecord{{ID: "p-1", ProjectID: "p", Metadata: domain.SessionMetadata{Branch: "ao/p-1/root"}}}, + projects: map[string]domain.ProjectRecord{"p": {ID: "p", Path: dir, RepoOriginURL: "https://github.com/o/r.git"}}, + prs: map[domain.SessionID][]domain.PullRequest{}, + checks: map[string][]domain.PullRequestCheck{}, + } + provider := &fakeProvider{ + repoGuards: map[string]ports.SCMGuardResult{prKey(testRepo, 0): {ETag: "origin"}, prKey(upstream, 0): {ETag: "up"}}, + openPRs: map[string][]ports.SCMPRObservation{ + prKey(upstream, 0): {{URL: "https://github.com/up/r/pull/9", Number: 9, SourceBranch: "ao/p-1/feat", HeadRepo: "stranger/r", TargetBranch: "main", HeadSHA: "sha9"}}, + }, + observations: map[string]ports.SCMObservation{}, + } + obs := newTestObserver(store, provider, &fakeLifecycle{}, time.Unix(1, 0).UTC()) + if err := obs.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if len(provider.fetchBatches) != 0 { + t.Fatalf("foreign-head upstream PR must not be fetched, got %#v", provider.fetchBatches) + } + if len(store.writes) != 0 { + t.Fatalf("foreign-head upstream PR must not be persisted, got %d writes", len(store.writes)) + } +} + // A newly discovered open PR is persisted as a baseline row during discovery, // before the refresh/lifecycle pass. This is what lets a same-poll terminal // observation for a sibling PR see the open PR in the store and avoid completing From 365c99f705f884971da6a18656907007dff9a0be Mon Sep 17 00:00:00 2001 From: Vaibhaav Tiwari <155460282+Vaibhaav-Tiwari@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:59:51 +0530 Subject: [PATCH 02/15] docs: add first-timer README overview (#2384) Co-authored-by: Vaibhaav --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0faa318d3..e9adb764c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,49 @@ An Agentic IDE that supervises parallel AI coding agents in isolated workspaces, ![Agent Orchestrator Dashboard](ao-dashboard-preview.png) + + +--- + +## What is Agent Orchestrator? + +Agent Orchestrator is a meta-harness agent IDE for running AI coding agents in parallel. It gives terminal-based agents like Claude Code, Codex, Cursor, Aider, Goose, and others a shared workspace where their sessions, terminals, branches, pull requests, and feedback loops can be supervised from one place. + +The agents still do the coding. AO provides the harness around them: isolated workspaces, live terminal access, session state, PR awareness, and automatic loops that send CI failures, review comments, and merge conflicts back to the right agent. Instead of manually coordinating a pile of agent terminals, AO turns parallel agent work into a managed workflow. + +--- + +## Why Agent Orchestrator? + +AI coding agents become much more useful when they can work in parallel, but parallel work gets messy quickly. Branches overlap, terminals get lost, CI failures need follow-up, review comments need replies, and merge conflicts have to reach the right worker. + +Agent Orchestrator is built to keep that loop visible and manageable. It helps you: + +- Start multiple agents from the same project without mixing their work +- Keep every session in a separate git worktree +- See which agents are working, waiting, finished, or blocked +- Route CI failures, review comments, and merge conflicts back to the right session +- Use different agent CLIs through one common supervisor + +--- + +## How it works + +At a high level, Agent Orchestrator follows a simple loop: + +1. Add a project you want agents to work on. +2. Start one or more sessions from the desktop app or CLI. +3. AO creates an isolated git worktree for each session. +4. AO launches the selected coding agent in that session's terminal runtime. +5. The local daemon watches session state, terminal activity, pull requests, CI, and review feedback. +6. The desktop app and CLI show the current state and let you send follow-up instructions to the right session. + +The result is a local control layer for agentic coding: agents still do the coding, while Agent Orchestrator keeps their workspaces, status, terminals, and feedback loops organized. + +--- + +
+ ### Witness AO's Journey on X @@ -41,7 +84,7 @@ An Agentic IDE that supervises parallel AI coding agents in isolated workspaces,
-[Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Documentation](#documentation) • [Contributing](#contributing) +[What is Agent Orchestrator?](#what-is-agent-orchestrator) • [Why Agent Orchestrator?](#why-agent-orchestrator) • [How it works](#how-it-works) • [Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Documentation](#documentation) • [Contributing](#contributing)
From 52d8c6051a010c10a6d1feb73b4c45c16c869984 Mon Sep 17 00:00:00 2001 From: Vaibhaav Tiwari <155460282+Vaibhaav-Tiwari@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:17:02 +0530 Subject: [PATCH 03/15] docs: add contributing guide (#2385) Co-authored-by: Vaibhaav --- CONTRIBUTING.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..7dbc0eca8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +We love contributions! Join our community on Discord to get started. + +## Join us on Discord + +[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white&logoSize=auto)](https://discord.com/invite/UZv7JjxbwG) + +**Daily contributor sync:** Every day at **10:00 PM IST** + +Get your issues verified by core contributors, ask questions, share progress, and learn from the community. New contributors are always welcome! + +**Why join Discord?** + +- Get your issues and PRs verified by core contributors before investing time +- Learn from experienced contributors in daily sync calls +- Share your progress and get feedback +- Get help troubleshooting in real-time +- Stay updated on the latest developments and roadmap + +## Quick Start + +1. **Join the Discord** - Connect with the community and get guidance +2. **Read the contributor contract** - See [AGENTS.md](AGENTS.md) for repo layout, daemon/API boundaries, and coding conventions +3. **Pick a focused problem** - Browse [open issues](https://github.com/AgentWrapper/agent-orchestrator/issues) and choose one small enough for a focused PR +4. **Open a clear PR** - Keep changes narrow, explain user-visible impact, link issues, include tests +5. **Iterate with contributors** - Use review feedback to tighten the PR until verified From f92ff1111af4c23cc95368603cb1171e6401cf16 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Fri, 3 Jul 2026 22:23:03 +0530 Subject: [PATCH 04/15] feat(release): add important flag to nightly feed manifests (#2378) Co-authored-by: Claude Fable 5 --- .github/workflows/frontend-nightly.yml | 10 +++++++++- frontend/scripts/feed.mjs | 18 +++++++++++++----- frontend/scripts/feed.test.mjs | 23 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/.github/workflows/frontend-nightly.yml b/.github/workflows/frontend-nightly.yml index c441b0bd7..7ad149c8f 100644 --- a/.github/workflows/frontend-nightly.yml +++ b/.github/workflows/frontend-nightly.yml @@ -9,6 +9,11 @@ on: schedule: - cron: "30 13 * * *" # 13:30 UTC = 19:00 IST daily workflow_dispatch: + inputs: + important: + description: 'Mark this nightly as an important update (escalates the in-app restart prompt). Retro-flag an already-published nightly by re-running only the publish-feed job with this input set.' + type: boolean + default: false jobs: guard: @@ -201,6 +206,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NIGHTLY_VERSION: ${{ needs.guard.outputs.version }} + NIGHTLY_IMPORTANT: ${{ inputs.important || 'false' }} run: | # Forge stamps package.json to VERSION without +build metadata and # publishes to v. Match it. @@ -208,7 +214,9 @@ jobs: tag="v$version" mkdir -p dist gh release download "$tag" --dir dist --clobber - node scripts/feed.mjs dist "$version" nightly + important_flag="" + [ "$NIGHTLY_IMPORTANT" = "true" ] && important_flag="--important" + node scripts/feed.mjs dist "$version" nightly $important_flag shopt -s nullglob assets=(dist/nightly*.yml dist/*.blockmap) if [ ${#assets[@]} -eq 0 ]; then echo "no feed assets generated" >&2; exit 1; fi diff --git a/frontend/scripts/feed.mjs b/frontend/scripts/feed.mjs index ee77b05f4..f9f5f73ab 100644 --- a/frontend/scripts/feed.mjs +++ b/frontend/scripts/feed.mjs @@ -3,6 +3,10 @@ // ESM (mirrors nightly-version.mjs) so CI runs `node scripts/feed.mjs` directly // and vitest unit-tests the pure functions. The only non-stdlib reach is the // blockmap wrapper (Task 1). +// Pass --important to emit `important: true` in each generated yml. An +// already-published nightly can be retro-flagged by re-running the feed job +// with --important set (or editing the yml and running +// `gh release upload TAG nightly*.yml --clobber`). import { readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { writeBlockmap } from "./blockmap.mjs"; @@ -34,7 +38,9 @@ export function feedFilename(channel, platform) { // buildYml serializes one platform's feed. files is [{ url, sha512, size }]; // for mac the arm64 entry comes first. The deprecated top-level path/sha512 // point at files[0]. blockMapSize is never written (forces sidecar differential). -export function buildYml(version, files, releaseDate) { +// When important is true, emits `important: true` after releaseDate so the +// in-app update prompt is escalated. +export function buildYml(version, files, releaseDate, important = false) { const lines = [`version: ${version}`, "files:"]; for (const f of files) { lines.push(` - url: ${f.url}`); @@ -44,12 +50,13 @@ export function buildYml(version, files, releaseDate) { lines.push(`path: ${files[0].url}`); lines.push(`sha512: ${files[0].sha512}`); lines.push(`releaseDate: '${releaseDate}'`); + if (important) lines.push("important: true"); return lines.join("\n") + "\n"; } // generateFeeds writes the yml + sidecar blockmaps for every platform present in // dir. version may carry +build metadata (nightly); strip it for the yml. -async function generateFeeds(dir, rawVersion, channel, releaseDate) { +async function generateFeeds(dir, rawVersion, channel, releaseDate, important = false) { const version = rawVersion.split("+")[0]; const sel = selectInstallers(readdirSync(dir), version); const groups = [ @@ -64,18 +71,19 @@ async function generateFeeds(dir, rawVersion, channel, releaseDate) { const { sha512, size } = await writeBlockmap(join(dir, name)); files.push({ url: name, sha512, size }); } - writeFileSync(join(dir, feedFilename(channel, platform)), buildYml(version, files, releaseDate)); + writeFileSync(join(dir, feedFilename(channel, platform)), buildYml(version, files, releaseDate, important)); } } -// CLI: node scripts/feed.mjs +// CLI: node scripts/feed.mjs [--important] if (import.meta.url === `file://${process.argv[1]}`) { const [, , dir, version, channel] = process.argv; if (!dir || !version || !channel) { process.stderr.write("usage: node feed.mjs \n"); process.exit(2); } - generateFeeds(dir, version, channel, new Date().toISOString()).catch((err) => { + const important = process.argv.includes("--important"); + generateFeeds(dir, version, channel, new Date().toISOString(), important).catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exit(1); }); diff --git a/frontend/scripts/feed.test.mjs b/frontend/scripts/feed.test.mjs index 745d07066..fd5c1bab3 100644 --- a/frontend/scripts/feed.test.mjs +++ b/frontend/scripts/feed.test.mjs @@ -69,4 +69,27 @@ describe("buildYml", () => { expect(lines[5]).toBe(" - url: Agent.Orchestrator-darwin-x64-0.10.4.zip"); expect(yml).toContain("path: Agent.Orchestrator-darwin-arm64-0.10.4.zip"); }); + + it("omits important key when flag is false (byte-identical to old output)", () => { + const yml = buildYml( + "0.10.4", + [{ url: "Agent.Orchestrator.Setup.0.10.4.exe", sha512: "AA/BB+cc==", size: 123 }], + "2026-06-27T12:00:00.000Z", + false, + ); + expect(yml).not.toContain("important"); + }); + + it("emits important: true as top-level key when flag is true", () => { + const yml = buildYml( + "0.10.4", + [{ url: "Agent.Orchestrator.Setup.0.10.4.exe", sha512: "AA/BB+cc==", size: 123 }], + "2026-06-27T12:00:00.000Z", + true, + ); + expect(yml).toContain("important: true\n"); + // must still have all existing fields + expect(yml).toContain("version: 0.10.4"); + expect(yml).toContain("releaseDate:"); + }); }); From 6f09a1fc78ec78ce4c81002363d0156a9500830e Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Fri, 3 Jul 2026 22:23:57 +0530 Subject: [PATCH 05/15] fix(frontend): show session display name in terminal toolbar header (#2382) * fix(frontend): show session display name in terminal toolbar header The terminal toolbar showed the raw session id. Show the display name (session.title, same field the sidebar renders) instead, and 'Orchestrator' for orchestrator sessions. Fixes #2380 Co-Authored-By: Claude Fable 5 * test(frontend): cover terminal toolbar session label Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../renderer/components/CenterPane.test.tsx | 38 +++++++++++++++++++ .../src/renderer/components/CenterPane.tsx | 6 ++- 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 frontend/src/renderer/components/CenterPane.test.tsx diff --git a/frontend/src/renderer/components/CenterPane.test.tsx b/frontend/src/renderer/components/CenterPane.test.tsx new file mode 100644 index 000000000..f4ebef8a1 --- /dev/null +++ b/frontend/src/renderer/components/CenterPane.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceSession } from "../types/workspace"; +import { CenterPane } from "./CenterPane"; + +// The terminal body pulls in xterm/SSE machinery irrelevant to the toolbar under test. +vi.mock("./TerminalPane", () => ({ TerminalPane: () =>
terminal body
})); + +const worker = { + id: "sess-1", + workspaceId: "proj-1", + workspaceName: "my-app", + title: "do the thing", + provider: "claude-code", + kind: "worker", + branch: "ao/sess-1", + status: "working", + updatedAt: "2026-06-10T00:00:00Z", + prs: [], +} satisfies WorkspaceSession; + +describe("CenterPane toolbar session label", () => { + it("shows the session display name for a worker", () => { + render(); + expect(screen.getByText("do the thing")).toBeInTheDocument(); + expect(screen.queryByText("sess-1")).not.toBeInTheDocument(); + }); + + it("shows 'Orchestrator' for an orchestrator session", () => { + render(); + expect(screen.getByText("Orchestrator")).toBeInTheDocument(); + }); + + it("shows 'No session' when there is no session", () => { + render(); + expect(screen.getByText("No session")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/components/CenterPane.tsx b/frontend/src/renderer/components/CenterPane.tsx index 09d494ae5..e0a2fca45 100644 --- a/frontend/src/renderer/components/CenterPane.tsx +++ b/frontend/src/renderer/components/CenterPane.tsx @@ -2,7 +2,7 @@ import { ChevronLeft, Maximize2, Minimize2, Shield } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type WheelEvent } from "react"; import type { Theme } from "../stores/ui-store"; import type { TerminalTarget } from "../types/terminal"; -import type { WorkspaceSession } from "../types/workspace"; +import { isOrchestratorSession, type WorkspaceSession } from "../types/workspace"; import { TerminalPane } from "./TerminalPane"; type CenterPaneProps = { @@ -99,7 +99,9 @@ export function CenterPane({ session, theme, daemonReady, terminalTarget, onSele
TERMINAL - {session?.id ?? "No session"} + + {!session ? "No session" : isOrchestratorSession(session) ? "Orchestrator" : session.title} +
+ + Auto-spawns a worker session for each matching GitHub issue. + + + )} +
+ {form.enabled && ( + <> + {repoPreview && ( + + {repoPreview.value ? ( + + {repoPreview.value} + + ) : ( + + Could not detect a GitHub repo from this project's git origin. + + )} + + )} + + onChange({ assignee: e.target.value })} + placeholder="type username or * for any" + /> + + {!compact && needsRule && ( +

Enabling intake requires an assignee.

+ )} + + )} +
+ ); +} + +function IntakeField({ label, htmlFor, children }: { label: string; htmlFor?: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 41bb524ac..914790320 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -239,4 +239,76 @@ describe("ProjectSettingsForm", () => { }); expect(await screen.findByText("Saved.")).toBeInTheDocument(); }); + + it("saves GitHub tracker intake settings, deriving the repo from the project's git origin", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "git@github.com:acme/project-one.git", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, + }, + }, + }, + error: undefined, + }); + + renderSettings(); + + await userEvent.click(await screen.findByLabelText("Enable issue intake")); + + // Repository is display-only, derived from the project's own git origin — no input to + // fill. Assignee is the only eligibility rule in v1. + expect(screen.getByRole("link", { name: "acme/project-one" })).toHaveAttribute( + "href", + "https://github.com/acme/project-one", + ); + await userEvent.type(screen.getByLabelText("Assignee"), "octocat"); + + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + const body = putMock.mock.calls[0]?.[1]?.body; + expect(body.config.trackerIntake).toEqual({ + enabled: true, + provider: "github", + assignee: "octocat", + }); + }); + + it("blocks save when intake is enabled with no assignee", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "git@github.com:acme/project-one.git", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, + }, + }, + }, + error: undefined, + }); + + renderSettings(); + + await userEvent.click(await screen.findByLabelText("Enable issue intake")); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findAllByText("Enabling intake requires an assignee.")).toHaveLength(2); + expect(putMock).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index c6be21a19..59f30fc63 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -6,6 +6,7 @@ import { DEFAULT_PROJECT_AGENT } from "../lib/agent-options"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { RequiredAgentField } from "./CreateProjectAgentSheet"; import { DashboardSubhead } from "./DashboardSubhead"; +import { buildIntake, deriveGitHubRepo, IntakeFields, type IntakeForm, intakeNeedsRule } from "./IntakeFields"; import { Button } from "./ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Label } from "./ui/label"; @@ -13,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". type Project = components["schemas"]["Project"]; type ProjectConfig = components["schemas"]["ProjectConfig"]; +type TrackerIntakeConfig = components["schemas"]["TrackerIntakeConfig"]; const PERMISSION_MODE_OPTIONS = [ { value: "default", label: "Default" }, @@ -67,6 +69,7 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) { function SettingsBody({ project, projectId, onSaved }: { project: Project; projectId: string; onSaved: () => void }) { const queryClient = useQueryClient(); const config = project.config ?? {}; + const intake: TrackerIntakeConfig = config.trackerIntake ?? {}; const [form, setForm] = useState({ defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "", sessionPrefix: config.sessionPrefix ?? "", @@ -75,8 +78,32 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje model: config.agentConfig?.model ?? "", permissions: config.agentConfig?.permissions ?? "", reviewerHarness: config.reviewers?.[0]?.harness ?? "", + intakeEnabled: intake.enabled ?? false, + intakeRepo: intake.repo ?? "", + intakeAssignee: intake.assignee ?? "", }); const [savedAt, setSavedAt] = useState(null); + const [validationError, setValidationError] = useState(null); + const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === ""; + + // The Electron app only registers git projects today, so the daemon always has a usable + // git origin to derive owner/repo from (trackerRepo() in observer.go) when + // trackerIntake.repo is unset — there's no manual override input here. This mirrors that + // same derivation client-side purely for display (a link to the repo being polled). + const intakeForm: IntakeForm = { + enabled: form.intakeEnabled, + repo: form.intakeRepo, + assignee: form.intakeAssignee, + }; + const patchIntake = (patch: Partial) => + setForm((f) => ({ + ...f, + intakeEnabled: patch.enabled ?? f.intakeEnabled, + intakeRepo: patch.repo ?? f.intakeRepo, + intakeAssignee: patch.assignee ?? f.intakeAssignee, + })); + const effectiveIntakeRepo = form.intakeRepo.trim() || deriveGitHubRepo(project.repo); + const intakeIncomplete = intakeNeedsRule(intakeForm); const mutation = useMutation({ mutationFn: async () => { @@ -94,6 +121,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje permissions: form.permissions || undefined, }), reviewers: form.reviewerHarness ? [{ harness: form.reviewerHarness }] : undefined, + trackerIntake: buildIntake(intakeForm), }; const { error } = await apiClient.PUT("/api/v1/projects/{id}/config", { params: { path: { id: projectId } }, @@ -114,6 +142,15 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje onSubmit={(event) => { event.preventDefault(); setSavedAt(null); + if (missingRequiredAgent) { + setValidationError("Worker and orchestrator agents are required."); + return; + } + if (intakeIncomplete) { + setValidationError("Enabling intake requires an assignee."); + return; + } + setValidationError(null); mutation.mutate(); }} > @@ -207,10 +244,20 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje + + + Tracker intake + + + + + +
+ {validationError && {validationError}} {mutation.isError && ( {mutation.error instanceof Error ? mutation.error.message : "Save failed"} diff --git a/frontend/src/renderer/components/SessionInspector.test.tsx b/frontend/src/renderer/components/SessionInspector.test.tsx index 4ae34cea9..78a32c608 100644 --- a/frontend/src/renderer/components/SessionInspector.test.tsx +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -345,6 +345,13 @@ describe("SessionInspector tabs", () => { const tabs = screen.getAllByRole("tab").map((el) => el.textContent?.trim()); expect(tabs).toEqual(["Summary", "Reviews", "Browser"]); }); + + it("shows the intake issue id in the summary overview when present", () => { + renderWithQuery(); + + expect(screen.getByText("Issue")).toBeInTheDocument(); + expect(screen.getByText("github:acme/project-one#42")).toBeInTheDocument(); + }); }); describe("SessionInspector reviews tab", () => { diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index 80d8c5918..9be031a8e 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -8,7 +8,7 @@ import { formatTimeCompact } from "../lib/format-time"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import { prBrowserUrl, prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display"; import type { SessionActivityState, WorkspaceSession } from "../types/workspace"; -import { sortedPRs } from "../types/workspace"; +import { canonicalTrackerIssueId, sortedPRs } from "../types/workspace"; import { BrowserPanelView } from "./BrowserPanel"; import type { BrowserViewModel } from "../hooks/useBrowserView"; import { Badge } from "./ui/badge"; @@ -169,6 +169,7 @@ function SummaryView({ session }: { session: WorkspaceSession }) { const prSummaries = sessionPRDisplaySummaries(session, query.data); const prSectionTitle = prSummaries.length > 1 ? `Pull requests (${prSummaries.length})` : "Pull request"; const branchLabel = session.branch || `session/${session.id}`; + const issueId = canonicalTrackerIssueId(session.issueId); return (
@@ -191,6 +192,7 @@ function SummaryView({ session }: { session: WorkspaceSession }) {
+ {issueId && } diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 7da1b8a30..261ca406c 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -2,7 +2,13 @@ import { useState, type KeyboardEvent } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { Plus } from "lucide-react"; -import { type AttentionZone, type WorkspaceSession, attentionZone, workerSessions } from "../types/workspace"; +import { + type AttentionZone, + type WorkspaceSession, + attentionZone, + canonicalTrackerIssueId, + workerSessions, +} from "../types/workspace"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { DashboardSubhead } from "./DashboardSubhead"; @@ -260,6 +266,7 @@ function ZoneColumn({ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: () => void }) { const badge = sessionBadge(session); + const issueId = canonicalTrackerIssueId(session.issueId); const branch = session.branch || ""; const showBranch = branch !== "" && !sameLabel(branch, session.title) && !sameLabel(branch, session.id); const prSummaries = sessionPRDisplaySummaries(session, useSessionScmSummary(session.id).data); @@ -285,6 +292,14 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: ( {badge.label} + {issueId && ( + + {issueId} + + )} {agentLabel(session.provider)} diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 81f8a9bc3..e1200349f 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -81,7 +81,7 @@ type SidebarProps = { underTopbar?: boolean; workspaceError?: string; workspaces: WorkspaceSummary[]; - onCreateProject: (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise; + onCreateProject: (input: { path: string } & CreateProjectAgentSelection) => Promise; onRemoveProject: (projectId: string) => Promise; }; diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index 69daa089e..21b402872 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -62,6 +62,7 @@ describe("useWorkspaceQuery", () => { projectId: "proj-1", terminalHandleId: "term-1", displayName: "fix-bug", + issueId: "github:acme/project-one#42", harness: "claude-code", branch: "qa/modal-worker", status: "mergeable", @@ -97,6 +98,7 @@ describe("useWorkspaceQuery", () => { id: "sess-1", terminalHandleId: "term-1", title: "fix-bug", + issueId: "github:acme/project-one#42", provider: "claude-code", branch: "qa/modal-worker", status: "mergeable", diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index c07efa195..b46373aff 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -52,6 +52,7 @@ async function fetchWorkspaces(): Promise { workspaceId: project.id, workspaceName: project.name, title: session.displayName ?? session.issueId ?? session.id, + issueId: session.issueId, provider: toAgentProvider(session.harness), kind: session.kind === "orchestrator" ? "orchestrator" : session.kind === "worker" ? "worker" : undefined, branch: session.branch ?? `session/${session.id}`, diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index 72c12a11e..6dcbb2c42 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -14,6 +14,7 @@ import { ShellProvider } from "../lib/shell-context"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store"; import type { WorkspaceSummary } from "../types/workspace"; +import type { components } from "../../api/schema"; export const Route = createFileRoute("/_shell")({ // Prefetch the workspace list for the whole shell (parent loaders run before @@ -54,7 +55,12 @@ function ShellLayout() { ); const createProject = useCallback( - async (input: { path: string; workerAgent: string; orchestratorAgent: string }) => { + async (input: { + path: string; + workerAgent: string; + orchestratorAgent: string; + trackerIntake?: components["schemas"]["TrackerIntakeConfig"]; + }) => { void addRendererExceptionStep("Project add requested", { source: "project-add", operation: "project_add", @@ -67,6 +73,7 @@ function ShellLayout() { config: { worker: { agent: input.workerAgent }, orchestrator: { agent: input.orchestratorAgent }, + trackerIntake: input.trackerIntake, }, }, }); diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts index c0ffab127..0125bd75a 100644 --- a/frontend/src/renderer/types/workspace.test.ts +++ b/frontend/src/renderer/types/workspace.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { attentionZone, + canonicalTrackerIssueId, findProjectOrchestrator, sessionIsActive, sessionNeedsAttention, @@ -20,6 +21,14 @@ import { type WorkspaceSummary, } from "./workspace"; +describe("canonicalTrackerIssueId", () => { + it("keeps provider-prefixed intake ids and rejects manual task titles", () => { + expect(canonicalTrackerIssueId("github:acme/project#42")).toBe("github:acme/project#42"); + expect(canonicalTrackerIssueId("Fix fallback renderer")).toBeUndefined(); + expect(canonicalTrackerIssueId(undefined)).toBeUndefined(); + }); +}); + function sessionWith(overrides: Partial): WorkspaceSession { return { id: "sess-1", diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index 49a98f427..e8bb86f7e 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -118,6 +118,8 @@ export type WorkspaceSession = { workspaceId: string; workspaceName: string; title: string; + /** Raw issue/task identifier from the daemon. Intake ids are provider-prefixed. */ + issueId?: string; provider: AgentProvider; kind?: SessionKind; branch: string; @@ -156,6 +158,22 @@ export type WorkspaceSession = { displayStatus?: WorkerDisplayStatus; }; +// Tracker providers whose ids the intake daemon stamps sessions with, in +// ":" form. Adding a provider (Linear, Jira, ...) later is +// just another prefix in this list — no caller of canonicalTrackerIssueId +// needs to change. +const TRACKER_PROVIDER_PREFIXES = ["github:"] as const; + +/** + * The provider-prefixed issue id if `issueId` came from tracker intake, or + * undefined for manually created sessions (whose issueId, if any, is a plain + * task title with no provider prefix). + */ +export function canonicalTrackerIssueId(issueId?: string): string | undefined { + if (!issueId) return undefined; + return TRACKER_PROVIDER_PREFIXES.some((prefix) => issueId.startsWith(prefix)) ? issueId : undefined; +} + /** Glanceable worker status. Maps 1:1 to the accent colors in DESIGN.md. */ export type WorkerDisplayStatus = "working" | "needs_you" | "mergeable" | "ci_failed" | "no_signal" | "done" | "unknown"; From a639e2025cff346a9ea97296c94f121bda34c837 Mon Sep 17 00:00:00 2001 From: NIKHIL ACHALE <96230495+nikhilachale@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:59:34 +0530 Subject: [PATCH 10/15] feat: add agent catalog/auth API and safer orchestrator switching (#2309) * feat: add agent catalog API and integrate with project settings - Implemented AgentsController to handle /agents endpoint, returning a list of supported and installed agents. - Created agent inventory service to manage agent data and detect installed agents. - Updated ProjectSettingsForm to fetch and display agent information, including installed and supported agents. - Enhanced error handling for agent detection and orchestrator restarts. - Added tests for agent catalog and service to ensure correct functionality and error handling. * Implement agent authorization status checks and update frontend to reflect changes - Added `AuthStatus` method to various agent plugins to check authorization status using CLI probes. - Introduced `authprobe` package to handle common CLI command checks for agent authorization. - Updated backend tests to include scenarios for authorized and unauthorized agents. - Modified frontend API schema to include `authorized` counts and `authStatus` for agents. - Enhanced `ProjectSettingsForm` to display authorized agents and their statuses, including prompts for login when necessary. - Adjusted agent selection logic to prioritize authorized agents and provide feedback for unauthorized or uninstalled agents. * fix: simplify orchestrator replacement retry flow * refactor: cache agent catalog ,remove AgentCounts schema and related references from API and frontend * fix: clarify orchestrator replacement recovery state * feat: enhance project settings and agent management - Updated NewTaskDialog tests to increase timeout for async operations. - Modified ProjectSettingsForm tests to improve agent handling and validation messages. - Refactored ProjectSettingsForm component to streamline agent selection and validation logic. - Introduced new agent service to manage agent inventory and authentication status. - Improved Sidebar tests to ensure proper agent options are loaded and handled. - Enhanced SessionsBoard component by removing unused imports and optimizing state management. - Fixed Select component styling for better consistency in UI. - Added error handling for AO daemon readiness in ShellLayout. * chore: format with prettier [skip ci] * feat: add AuthStatus method documentation and improve error handling in session manager * feat: enhance agent authentication and configuration management - Implement local authentication status checks for PI, Qwen, and Vibe agents. - Introduce JSON-based authentication status retrieval for PI agents. - Add environment variable checks for Qwen agents and improve settings file parsing. - Enhance Vibe agent authentication with support for environment variables and session logs. - Update agent service to handle asynchronous probing for installed and authorized agents. - Modify session manager to support prompt delivery strategies based on agent capabilities. - Improve frontend agent selection UI with loading states and error handling. - Add tests for new authentication logic and session management features. * chore: format with prettier [skip ci] * test: fix session manager fake after rebase * feat: enhance agent authentication status checks - Implement local authentication status checks for the Devin, Droid, Kiro, and other agents. - Add support for reading credentials from specific configuration files and environment variables. - Introduce new tests for various agents to ensure proper authentication status reporting. - Refactor existing authentication logic to improve clarity and maintainability. - Remove deprecated agent setup warnings from the SessionsBoard component in the frontend. * feat: clear environment variables in auth status tests for Aider and OpenCode * feat: enhance error handling in authentication status functions and update component props * feat: integrate fallback agents in RequiredAgentField and streamline props handling * fix: refactor Sidebar test imports and parameters for clarity * refactor: remove orchestrator retirement logic and related tests - Deleted the RetireOrchestrator function and its associated error handling. - Removed tests related to orchestrator retirement and state management. - Simplified ProjectSettingsForm by eliminating orchestrator restart logic and related UI elements. - Updated API client mocks to reflect the removal of orchestrator-related functionality. * feat: enhance agent management and error handling - Added agent refresh functionality in ProjectSettingsForm with UI updates for agent availability. - Implemented `refreshAgents` API call to fetch the latest agent catalog. - Updated agent selection logic to disable unavailable agents and show appropriate error messages. - Enhanced error handling in `apiErrorMessage` to include daemon error codes alongside messages. - Created new test cases for agent availability and error handling in Sidebar component. - Introduced `ResolveBinary` method for multiple agent adapters to standardize binary resolution. - Added new agent adapter files for various agents (e.g., Aider, Claude Code, etc.) to support binary resolution. * chore: format with prettier [skip ci] * fix: satisfy backend lint checks * refactor: clean up authentication logic and improve error handling across agents * chore: format with prettier [skip ci] * feat(authprobe): enhance status classification for authentication outputs and add tests for explicit false/true keys chore(docs): update README to remove outdated agent adapter contract references fix(components): improve agent selection logic to handle unknown auth status and update related tests * chore: format with prettier [skip ci] * refactor: remove shell environment authentication logic and update related tests * feat(tests): integrate QueryClient for agent data in CreateProjectAgentSheet tests --------- Co-authored-by: github-actions[bot] --- README.md | 1 - .../internal/adapters/agent/agy/agy_test.go | 12 + backend/internal/adapters/agent/agy/auth.go | 17 + .../internal/adapters/agent/agy/install.go | 8 + backend/internal/adapters/agent/aider/auth.go | 131 +++ .../adapters/agent/aider/auth_test.go | 77 ++ .../internal/adapters/agent/aider/install.go | 8 + backend/internal/adapters/agent/amp/auth.go | 121 +++ .../internal/adapters/agent/amp/auth_test.go | 99 ++ .../internal/adapters/agent/amp/install.go | 8 + .../internal/adapters/agent/auggie/auth.go | 40 + .../adapters/agent/auggie/auth_test.go | 54 + .../internal/adapters/agent/auggie/install.go | 8 + .../adapters/agent/authprobe/authprobe.go | 134 +++ .../agent/authprobe/authprobe_test.go | 96 ++ .../internal/adapters/agent/autohand/auth.go | 75 ++ .../adapters/agent/autohand/auth_test.go | 76 ++ .../adapters/agent/autohand/install.go | 8 + .../adapters/agent/claudecode/claudecode.go | 107 ++ .../agent/claudecode/claudecode_test.go | 91 ++ .../adapters/agent/claudecode/install.go | 8 + backend/internal/adapters/agent/cline/auth.go | 122 +++ .../adapters/agent/cline/auth_test.go | 118 +++ .../internal/adapters/agent/cline/cline.go | 32 +- .../adapters/agent/cline/cline_test.go | 25 +- .../internal/adapters/agent/cline/install.go | 8 + .../internal/adapters/agent/codex/codex.go | 45 +- .../adapters/agent/codex/codex_test.go | 32 + .../internal/adapters/agent/codex/install.go | 8 + .../adapters/agent/continueagent/auth.go | 79 ++ .../adapters/agent/continueagent/auth_test.go | 50 + .../agent/continueagent/continueagent_test.go | 6 + .../adapters/agent/continueagent/install.go | 8 + .../internal/adapters/agent/copilot/auth.go | 160 +++ .../adapters/agent/copilot/copilot.go | 53 +- .../adapters/agent/copilot/copilot_test.go | 114 ++- .../adapters/agent/copilot/install.go | 8 + backend/internal/adapters/agent/crush/auth.go | 84 ++ .../adapters/agent/crush/auth_test.go | 42 + .../internal/adapters/agent/crush/install.go | 8 + .../internal/adapters/agent/cursor/auth.go | 113 +++ .../adapters/agent/cursor/auth_test.go | 96 ++ .../internal/adapters/agent/cursor/install.go | 8 + backend/internal/adapters/agent/devin/auth.go | 62 ++ .../adapters/agent/devin/auth_test.go | 61 ++ .../internal/adapters/agent/devin/install.go | 8 + backend/internal/adapters/agent/droid/auth.go | 89 ++ .../adapters/agent/droid/auth_test.go | 72 ++ .../internal/adapters/agent/droid/install.go | 8 + backend/internal/adapters/agent/goose/auth.go | 167 +++ .../internal/adapters/agent/goose/goose.go | 12 +- .../adapters/agent/goose/goose_test.go | 83 ++ .../internal/adapters/agent/goose/install.go | 8 + backend/internal/adapters/agent/grok/auth.go | 74 ++ .../internal/adapters/agent/grok/auth_test.go | 76 ++ .../internal/adapters/agent/grok/install.go | 8 + .../internal/adapters/agent/kilocode/auth.go | 194 ++++ .../adapters/agent/kilocode/auth_test.go | 165 +++ .../adapters/agent/kilocode/install.go | 8 + backend/internal/adapters/agent/kimi/auth.go | 88 ++ .../internal/adapters/agent/kimi/auth_test.go | 79 ++ .../internal/adapters/agent/kimi/install.go | 8 + backend/internal/adapters/agent/kiro/auth.go | 36 + .../internal/adapters/agent/kiro/install.go | 8 + .../internal/adapters/agent/kiro/kiro_test.go | 46 + .../adapters/agent/opencode/install.go | 8 + .../adapters/agent/opencode/opencode.go | 196 +++- .../adapters/agent/opencode/opencode_test.go | 275 +++++ backend/internal/adapters/agent/pi/auth.go | 85 ++ .../internal/adapters/agent/pi/auth_test.go | 42 + backend/internal/adapters/agent/pi/install.go | 8 + backend/internal/adapters/agent/qwen/auth.go | 116 +++ .../internal/adapters/agent/qwen/auth_test.go | 78 ++ .../internal/adapters/agent/qwen/install.go | 8 + .../adapters/agent/registry/registry.go | 10 +- .../adapters/agent/registry/registry_test.go | 20 + backend/internal/adapters/agent/vibe/auth.go | 181 ++++ .../internal/adapters/agent/vibe/install.go | 8 + backend/internal/adapters/agent/vibe/vibe.go | 16 +- .../internal/adapters/agent/vibe/vibe_test.go | 99 +- backend/internal/cli/stop_test.go | 1 + backend/internal/daemon/daemon.go | 5 +- backend/internal/httpd/api.go | 6 + backend/internal/httpd/apispec/openapi.yaml | 95 ++ .../internal/httpd/apispec/specgen/build.go | 28 + backend/internal/httpd/controllers/agents.go | 55 + .../internal/httpd/controllers/agents_test.go | 94 ++ backend/internal/httpd/controllers/dto.go | 10 + backend/internal/httpd/server_test.go | 2 + backend/internal/observe/scm/observer_test.go | 1 + backend/internal/ports/agent.go | 28 + backend/internal/runfile/runfile_test.go | 4 + .../internal/service/agent/catalog_test.go | 303 ++++++ backend/internal/service/agent/service.go | 214 ++++ backend/internal/service/project/service.go | 50 +- .../internal/service/project/service_test.go | 29 + backend/internal/service/session/claim_pr.go | 6 +- backend/internal/session_manager/manager.go | 15 +- .../internal/session_manager/manager_test.go | 144 +-- docs/README.md | 1 - docs/agent/README.md | 950 ------------------ frontend/src/api/schema.ts | 127 +++ .../CreateProjectAgentSheet.test.tsx | 33 +- .../components/CreateProjectAgentSheet.tsx | 138 ++- .../components/NewTaskDialog.test.tsx | 95 +- .../src/renderer/components/NewTaskDialog.tsx | 56 +- .../components/ProjectSettingsForm.test.tsx | 222 ++-- .../components/ProjectSettingsForm.tsx | 46 +- .../components/SessionsBoard.test.tsx | 40 + .../src/renderer/components/SessionsBoard.tsx | 16 +- .../src/renderer/components/Sidebar.test.tsx | 157 ++- .../src/renderer/components/ui/select.tsx | 6 +- frontend/src/renderer/hooks/useAgentsQuery.ts | 30 + frontend/src/renderer/lib/agent-options.ts | 4 - frontend/src/renderer/lib/api-client.test.ts | 26 +- frontend/src/renderer/lib/api-client.ts | 7 +- .../renderer/lib/spawn-orchestrator.test.ts | 18 + .../src/renderer/lib/spawn-orchestrator.ts | 9 +- frontend/src/renderer/routes/_shell.tsx | 18 +- frontend/src/renderer/types/workspace.test.ts | 6 + frontend/src/renderer/types/workspace.ts | 9 +- 121 files changed, 6685 insertions(+), 1327 deletions(-) create mode 100644 backend/internal/adapters/agent/agy/auth.go create mode 100644 backend/internal/adapters/agent/agy/install.go create mode 100644 backend/internal/adapters/agent/aider/auth.go create mode 100644 backend/internal/adapters/agent/aider/auth_test.go create mode 100644 backend/internal/adapters/agent/aider/install.go create mode 100644 backend/internal/adapters/agent/amp/auth.go create mode 100644 backend/internal/adapters/agent/amp/auth_test.go create mode 100644 backend/internal/adapters/agent/amp/install.go create mode 100644 backend/internal/adapters/agent/auggie/auth.go create mode 100644 backend/internal/adapters/agent/auggie/auth_test.go create mode 100644 backend/internal/adapters/agent/auggie/install.go create mode 100644 backend/internal/adapters/agent/authprobe/authprobe.go create mode 100644 backend/internal/adapters/agent/authprobe/authprobe_test.go create mode 100644 backend/internal/adapters/agent/autohand/auth.go create mode 100644 backend/internal/adapters/agent/autohand/auth_test.go create mode 100644 backend/internal/adapters/agent/autohand/install.go create mode 100644 backend/internal/adapters/agent/claudecode/install.go create mode 100644 backend/internal/adapters/agent/cline/auth.go create mode 100644 backend/internal/adapters/agent/cline/auth_test.go create mode 100644 backend/internal/adapters/agent/cline/install.go create mode 100644 backend/internal/adapters/agent/codex/install.go create mode 100644 backend/internal/adapters/agent/continueagent/auth.go create mode 100644 backend/internal/adapters/agent/continueagent/auth_test.go create mode 100644 backend/internal/adapters/agent/continueagent/install.go create mode 100644 backend/internal/adapters/agent/copilot/auth.go create mode 100644 backend/internal/adapters/agent/copilot/install.go create mode 100644 backend/internal/adapters/agent/crush/auth.go create mode 100644 backend/internal/adapters/agent/crush/auth_test.go create mode 100644 backend/internal/adapters/agent/crush/install.go create mode 100644 backend/internal/adapters/agent/cursor/auth.go create mode 100644 backend/internal/adapters/agent/cursor/auth_test.go create mode 100644 backend/internal/adapters/agent/cursor/install.go create mode 100644 backend/internal/adapters/agent/devin/auth.go create mode 100644 backend/internal/adapters/agent/devin/auth_test.go create mode 100644 backend/internal/adapters/agent/devin/install.go create mode 100644 backend/internal/adapters/agent/droid/auth.go create mode 100644 backend/internal/adapters/agent/droid/auth_test.go create mode 100644 backend/internal/adapters/agent/droid/install.go create mode 100644 backend/internal/adapters/agent/goose/auth.go create mode 100644 backend/internal/adapters/agent/goose/install.go create mode 100644 backend/internal/adapters/agent/grok/auth.go create mode 100644 backend/internal/adapters/agent/grok/auth_test.go create mode 100644 backend/internal/adapters/agent/grok/install.go create mode 100644 backend/internal/adapters/agent/kilocode/auth.go create mode 100644 backend/internal/adapters/agent/kilocode/auth_test.go create mode 100644 backend/internal/adapters/agent/kilocode/install.go create mode 100644 backend/internal/adapters/agent/kimi/auth.go create mode 100644 backend/internal/adapters/agent/kimi/auth_test.go create mode 100644 backend/internal/adapters/agent/kimi/install.go create mode 100644 backend/internal/adapters/agent/kiro/auth.go create mode 100644 backend/internal/adapters/agent/kiro/install.go create mode 100644 backend/internal/adapters/agent/opencode/install.go create mode 100644 backend/internal/adapters/agent/pi/auth.go create mode 100644 backend/internal/adapters/agent/pi/auth_test.go create mode 100644 backend/internal/adapters/agent/pi/install.go create mode 100644 backend/internal/adapters/agent/qwen/auth.go create mode 100644 backend/internal/adapters/agent/qwen/auth_test.go create mode 100644 backend/internal/adapters/agent/qwen/install.go create mode 100644 backend/internal/adapters/agent/vibe/auth.go create mode 100644 backend/internal/adapters/agent/vibe/install.go create mode 100644 backend/internal/httpd/controllers/agents.go create mode 100644 backend/internal/httpd/controllers/agents_test.go create mode 100644 backend/internal/service/agent/catalog_test.go create mode 100644 backend/internal/service/agent/service.go delete mode 100644 docs/agent/README.md create mode 100644 frontend/src/renderer/components/SessionsBoard.test.tsx create mode 100644 frontend/src/renderer/hooks/useAgentsQuery.ts diff --git a/README.md b/README.md index e9adb764c..f51285142 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,6 @@ For detailed architecture diagrams, data flows, and load-bearing rules, see [arc | [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules | | [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules | | [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract | -| [Agent Adapter Contract](docs/agent/README.md) | Agent adapter interface and hook behavior | --- diff --git a/backend/internal/adapters/agent/agy/agy_test.go b/backend/internal/adapters/agent/agy/agy_test.go index b51205003..db6202c2e 100644 --- a/backend/internal/adapters/agent/agy/agy_test.go +++ b/backend/internal/adapters/agent/agy/agy_test.go @@ -200,3 +200,15 @@ func TestHooksLifecycle(t *testing.T) { t.Fatal("expected hooks to be uninstalled after UninstallHooks") } } + +func TestAuthStatus(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + + status, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Errorf("AuthStatus() = %v, want AgentAuthStatusAuthorized", status) + } +} diff --git a/backend/internal/adapters/agent/agy/auth.go b/backend/internal/adapters/agent/agy/auth.go new file mode 100644 index 000000000..658519a7d --- /dev/null +++ b/backend/internal/adapters/agent/agy/auth.go @@ -0,0 +1,17 @@ +package agy + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if _, err := p.ResolveBinary(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } + return ports.AgentAuthStatusAuthorized, nil +} diff --git a/backend/internal/adapters/agent/agy/install.go b/backend/internal/adapters/agent/agy/install.go new file mode 100644 index 000000000..223e7448d --- /dev/null +++ b/backend/internal/adapters/agent/agy/install.go @@ -0,0 +1,8 @@ +package agy + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.agyBinary(ctx) +} diff --git a/backend/internal/adapters/agent/aider/auth.go b/backend/internal/adapters/agent/aider/auth.go new file mode 100644 index 000000000..e06b090ca --- /dev/null +++ b/backend/internal/adapters/agent/aider/auth.go @@ -0,0 +1,131 @@ +package aider + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if _, err := p.ResolveBinary(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := aiderLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +var aiderAPIKeyEnvVars = []string{ + "AIDER_API_KEY", + "AIDER_OPENAI_API_KEY", + "AIDER_ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", +} + +func aiderLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range aiderAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + for _, path := range aiderConfigPaths() { + status, ok, err := aiderAuthStatusFromFile(path) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if ok { + return status, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func aiderConfigPaths() []string { + paths := []string{} + if cwd, err := os.Getwd(); err == nil && cwd != "" { + paths = append(paths, + filepath.Join(cwd, ".env"), + filepath.Join(cwd, ".aider.conf.yml"), + filepath.Join(cwd, ".aider.conf.yaml"), + ) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + paths = append(paths, + filepath.Join(home, ".env"), + filepath.Join(home, ".aider.conf.yml"), + filepath.Join(home, ".aider.conf.yaml"), + ) + } + return paths +} + +func aiderAuthStatusFromFile(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + text := strings.TrimSpace(string(data)) + if text == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + if fileContainsAPIKey(text) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func fileContainsAPIKey(text string) bool { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + lower := strings.ToLower(line) + if !strings.Contains(lower, "api") || !strings.Contains(lower, "key") { + continue + } + if valueAfterAssignment(line) != "" { + return true + } + } + return false +} + +func valueAfterAssignment(line string) string { + for _, sep := range []string{"=", ":"} { + before, after, ok := strings.Cut(line, sep) + if !ok || !strings.Contains(strings.ToLower(before), "key") { + continue + } + value := strings.Trim(strings.TrimSpace(after), `"'`) + if value != "" && !strings.EqualFold(value, "null") && !strings.EqualFold(value, "none") { + return value + } + } + return "" +} diff --git a/backend/internal/adapters/agent/aider/auth_test.go b/backend/internal/adapters/agent/aider/auth_test.go new file mode 100644 index 000000000..6635c7b9f --- /dev/null +++ b/backend/internal/adapters/agent/aider/auth_test.go @@ -0,0 +1,77 @@ +package aider + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAiderLocalAuthStatusAuthorizedWithProviderEnv(t *testing.T) { + clearAiderAuthEnv(t) + t.Setenv("OPENAI_API_KEY", "sk-test") + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAiderLocalAuthStatusAuthorizedWithConfigFile(t *testing.T) { + clearAiderAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.WriteFile(filepath.Join(home, ".aider.conf.yml"), []byte("openai-api-key: sk-test\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAiderLocalAuthStatusAuthorizedWithDotEnv(t *testing.T) { + clearAiderAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.WriteFile(filepath.Join(home, ".env"), []byte("ANTHROPIC_API_KEY=sk-ant-test\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAiderLocalAuthStatusUnknownWhenMissing(t *testing.T) { + clearAiderAuthEnv(t) + t.Setenv("HOME", t.TempDir()) + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func clearAiderAuthEnv(t *testing.T) { + t.Helper() + for _, name := range aiderAPIKeyEnvVars { + t.Setenv(name, "") + } +} diff --git a/backend/internal/adapters/agent/aider/install.go b/backend/internal/adapters/agent/aider/install.go new file mode 100644 index 000000000..6b86b07b1 --- /dev/null +++ b/backend/internal/adapters/agent/aider/install.go @@ -0,0 +1,8 @@ +package aider + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.aiderBinary(ctx) +} diff --git a/backend/internal/adapters/agent/amp/auth.go b/backend/internal/adapters/agent/amp/auth.go new file mode 100644 index 000000000..57e7c18a8 --- /dev/null +++ b/backend/internal/adapters/agent/amp/auth.go @@ -0,0 +1,121 @@ +package amp + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := ampLocalAuthStatus(ctx); err != nil || ok { + return status, err + } + return ampUsageAuthStatus(ctx, binary) +} + +func ampLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("AMP_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + status, ok, err := ampSettingsAuthStatus(ampSettingsPath()) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + return status, ok, nil +} + +func ampSettingsPath() string { + if path := strings.TrimSpace(os.Getenv("AMP_SETTINGS_FILE")); path != "" { + return expandHome(path) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".config", "amp", "settings.json") + } + return "" +} + +func ampSettingsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + if path == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ports.AgentAuthStatusUnknown, false, nil + } + return ports.AgentAuthStatusUnknown, false, err + } + var settings map[string]any + if err := json.Unmarshal(data, &settings); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, key := range []string{"amp.apiKey", "amp.api_key", "apiKey", "api_key"} { + if value, ok := settings[key]; ok { + if strings.TrimSpace(stringValue(value)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func ampUsageAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + out, err := authprobe.CmdRunner(probeCtx, binary, "usage", "--no-color") + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +func stringValue(value any) string { + switch v := value.(type) { + case string: + return v + default: + return "" + } +} + +func expandHome(path string) string { + if path == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + } + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + } + return path +} diff --git a/backend/internal/adapters/agent/amp/auth_test.go b/backend/internal/adapters/agent/amp/auth_test.go new file mode 100644 index 000000000..662e9ed7c --- /dev/null +++ b/backend/internal/adapters/agent/amp/auth_test.go @@ -0,0 +1,99 @@ +package amp + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + t.Setenv("AMP_API_KEY", "amp-key") + + got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAmpSettingsAuthStatusAuthorizedWithAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(path, []byte(`{"amp.apiKey":"amp-key"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := ampSettingsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAmpSettingsAuthStatusUnauthorizedWithEmptyAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(path, []byte(`{"amp.apiKey":""}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := ampSettingsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestAmpUsageAuthStatusAuthorizedOnSuccessfulUsage(t *testing.T) { + t.Setenv("AMP_API_KEY", "") + t.Setenv("AMP_SETTINGS_FILE", filepath.Join(t.TempDir(), "missing-settings.json")) + restore := mockAuthProbeRunner(t, func(ctx context.Context, name string, arg ...string) ([]byte, error) { + if name != "amp" || !reflect.DeepEqual(arg, []string{"usage", "--no-color"}) { + t.Fatalf("command = %s %#v, want amp usage --no-color", name, arg) + } + return []byte("Credits remaining: 100"), nil + }) + defer restore() + + got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAmpUsageAuthStatusUnauthorizedFromUsageOutput(t *testing.T) { + t.Setenv("AMP_API_KEY", "") + t.Setenv("AMP_SETTINGS_FILE", filepath.Join(t.TempDir(), "missing-settings.json")) + restore := mockAuthProbeRunner(t, func(ctx context.Context, name string, arg ...string) ([]byte, error) { + return []byte("login required"), errors.New("exit status 1") + }) + defer restore() + + got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusUnauthorized) + } +} + +func mockAuthProbeRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = runner + return func() { authprobe.CmdRunner = previous } +} diff --git a/backend/internal/adapters/agent/amp/install.go b/backend/internal/adapters/agent/amp/install.go new file mode 100644 index 000000000..20a45a9f5 --- /dev/null +++ b/backend/internal/adapters/agent/amp/install.go @@ -0,0 +1,8 @@ +package amp + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.ampBinary(ctx) +} diff --git a/backend/internal/adapters/agent/auggie/auth.go b/backend/internal/adapters/agent/auggie/auth.go new file mode 100644 index 000000000..29ae23e1f --- /dev/null +++ b/backend/internal/adapters/agent/auggie/auth.go @@ -0,0 +1,40 @@ +package auggie + +import ( + "context" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + return auggieAccountAuthStatus(ctx, binary) +} + +func auggieAccountAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + + out, err := authprobe.CmdRunner(probeCtx, binary, "account", "status") + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} diff --git a/backend/internal/adapters/agent/auggie/auth_test.go b/backend/internal/adapters/agent/auggie/auth_test.go new file mode 100644 index 000000000..2b204c5d7 --- /dev/null +++ b/backend/internal/adapters/agent/auggie/auth_test.go @@ -0,0 +1,54 @@ +package auggie + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusUsesAuggieAccountStatus(t *testing.T) { + restore := stubAuggieAuthRunner(t, func(_ context.Context, name string, arg ...string) ([]byte, error) { + if name != "auggie" { + t.Fatalf("binary = %q, want auggie", name) + } + if !reflect.DeepEqual(arg, []string{"account", "status"}) { + t.Fatalf("args = %#v, want [account status]", arg) + } + return []byte("Credits remaining: 42\n"), nil + }) + defer restore() + + status, err := (&Plugin{resolvedBinary: "auggie"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusUnauthorizedFromAuggieAccountStatus(t *testing.T) { + restore := stubAuggieAuthRunner(t, func(context.Context, string, ...string) ([]byte, error) { + return []byte("You are not currently logged in to Augment.\nRun 'auggie login' to authenticate first.\n"), errors.New("exit status 1") + }) + defer restore() + + status, err := (&Plugin{resolvedBinary: "auggie"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnauthorized) + } +} + +func stubAuggieAuthRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = runner + return func() { authprobe.CmdRunner = previous } +} diff --git a/backend/internal/adapters/agent/auggie/install.go b/backend/internal/adapters/agent/auggie/install.go new file mode 100644 index 000000000..2a605fdfe --- /dev/null +++ b/backend/internal/adapters/agent/auggie/install.go @@ -0,0 +1,8 @@ +package auggie + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.auggieBinary(ctx) +} diff --git a/backend/internal/adapters/agent/authprobe/authprobe.go b/backend/internal/adapters/agent/authprobe/authprobe.go new file mode 100644 index 000000000..ea15f8201 --- /dev/null +++ b/backend/internal/adapters/agent/authprobe/authprobe.go @@ -0,0 +1,134 @@ +package authprobe + +import ( + "context" + "os/exec" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// DefaultCommands are cheap local auth/status probes common across agent CLIs. +// Unsupported commands usually exit quickly with help text and are treated as +// unknown rather than unauthorized. +var DefaultCommands = [][]string{ + {"auth", "status"}, + {"login", "status"}, + {"providers", "list"}, +} + +// CmdRunner runs the command and returns the combined stdout/stderr. +// It is exposed as a package variable to allow mocking in tests. +var CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, arg...).CombinedOutput() +} + +// CLIStatus runs bounded local CLI probes and classifies their output. +func CLIStatus(ctx context.Context, binary string, commands [][]string) (ports.AgentAuthStatus, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, err + } + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + if len(commands) == 0 { + commands = DefaultCommands + } + for _, args := range commands { + status, err := commandStatus(ctx, binary, args) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status != ports.AgentAuthStatusUnknown { + return status, nil + } + } + return ports.AgentAuthStatusUnknown, nil +} + +func commandStatus(ctx context.Context, binary string, args []string) (ports.AgentAuthStatus, error) { + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := CmdRunner(probeCtx, binary, args...) + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + status := StatusFromText(string(out)) + if status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +// StatusFromText classifies common CLI auth/status output. +func StatusFromText(out string) ports.AgentAuthStatus { + text := strings.ToLower(out) + compactText := compact(text) + if hasAny(text, + "not logged in", + "not currently logged in", + "logged out", + "not authenticated", + "unauthenticated", + "authentication required", + "not authorized", + "unauthorized", + "login required", + "no credentials", + "0 credentials", + "no api key", + "no token", + `"loggedin": false`, + `"loggedin":false`, + ) || hasAny(compactText, + `"authenticated":false`, + `'authenticated':false`, + "authenticated:false", + "authenticated=false", + `"authorized":false`, + `'authorized':false`, + "authorized:false", + "authorized=false", + `"logged_in":false`, + `'logged_in':false`, + "logged_in:false", + "logged_in=false", + `"loggedin":false`, + `'loggedin':false`, + "loggedin:false", + "loggedin=false", + ) { + return ports.AgentAuthStatusUnauthorized + } + if hasAny(text, + "logged in", + "authenticated", + "authorized", + "token valid", + "api key found", + "credentials found", + `"loggedin": true`, + `"loggedin":true`, + ) { + return ports.AgentAuthStatusAuthorized + } + return ports.AgentAuthStatusUnknown +} + +func compact(text string) string { + return strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "").Replace(text) +} + +func hasAny(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, needle) { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/authprobe/authprobe_test.go b/backend/internal/adapters/agent/authprobe/authprobe_test.go new file mode 100644 index 000000000..0c8aa661c --- /dev/null +++ b/backend/internal/adapters/agent/authprobe/authprobe_test.go @@ -0,0 +1,96 @@ +package authprobe + +import ( + "context" + "errors" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCLIStatus_Mocked(t *testing.T) { + tests := []struct { + name string + mockOutput string + mockError error + wantStatus ports.AgentAuthStatus + wantError bool + }{ + { + name: "authorized status check", + mockOutput: "User is logged in and authenticated", + wantStatus: ports.AgentAuthStatusAuthorized, + }, + { + name: "unauthorized status check", + mockOutput: "You are not logged in", + wantStatus: ports.AgentAuthStatusUnauthorized, + }, + { + name: "unknown status check with exit error", + mockOutput: "command not found or invalid syntax", + mockError: errors.New("exit status 1"), + wantStatus: ports.AgentAuthStatusUnknown, + }, + { + name: "unknown status check with success but unrecognized output", + mockOutput: "some random output here", + wantStatus: ports.AgentAuthStatusUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save and restore CmdRunner + oldCmdRunner := CmdRunner + defer func() { CmdRunner = oldCmdRunner }() + + CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + return []byte(tt.mockOutput), tt.mockError + } + + status, err := CLIStatus(context.Background(), "mockbinary", nil) + if (err != nil) != tt.wantError { + t.Fatalf("unexpected error: %v", err) + } + if status != tt.wantStatus { + t.Errorf("CLIStatus() = %v, want %v", status, tt.wantStatus) + } + }) + } +} + +func TestStatusFromTextExplicitFalseKeys(t *testing.T) { + tests := []string{ + `{ "authenticated": false }`, + `{ "authorized": false }`, + `authenticated=false`, + `authorized: false`, + `{ "logged_in": false }`, + `{ "loggedIn": false }`, + } + + for _, out := range tests { + t.Run(out, func(t *testing.T) { + if got := StatusFromText(out); got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("StatusFromText(%q) = %q, want %q", out, got, ports.AgentAuthStatusUnauthorized) + } + }) + } +} + +func TestStatusFromTextExplicitTrueKeys(t *testing.T) { + tests := []string{ + `{ "authenticated": true }`, + `{ "authorized": true }`, + `{ "loggedIn": true }`, + } + + for _, out := range tests { + t.Run(out, func(t *testing.T) { + if got := StatusFromText(out); got != ports.AgentAuthStatusAuthorized { + t.Fatalf("StatusFromText(%q) = %q, want %q", out, got, ports.AgentAuthStatusAuthorized) + } + }) + } +} diff --git a/backend/internal/adapters/agent/autohand/auth.go b/backend/internal/adapters/agent/autohand/auth.go new file mode 100644 index 000000000..0471e8d31 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/auth.go @@ -0,0 +1,75 @@ +package autohand + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, err + } + return autohandConfigAuthStatus(autohandConfigPath()) +} + +func autohandConfigAuthStatus(configPath string) (ports.AgentAuthStatus, error) { + data, err := os.ReadFile(configPath) //nolint:gosec // path is the user's own Autohand config + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, nil + } + + var config map[string]json.RawMessage + if err := json.Unmarshal(data, &config); err != nil { + return ports.AgentAuthStatusUnknown, err + } + + authReady, authKnown := autohandCloudAuthReady(config) + if authReady { + return ports.AgentAuthStatusAuthorized, nil + } + if authKnown { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +func autohandCloudAuthReady(config map[string]json.RawMessage) (ready, known bool) { + authRaw, ok := config["auth"] + if !ok { + return false, false + } + var auth struct { + Token string `json:"token"` + } + if err := json.Unmarshal(authRaw, &auth); err != nil { + return false, false + } + return usableSecret(auth.Token), true +} + +func usableSecret(value string) bool { + normalized := strings.TrimSpace(value) + if normalized == "" { + return false + } + switch strings.ToLower(normalized) { + case "api key", "apikey", "your api key", "your-api-key", "your_api_key", "token", "your token", "your-token", "your_token", "changeme", "change-me", "replace-me", "replace_me": + return false + default: + return true + } +} diff --git a/backend/internal/adapters/agent/autohand/auth_test.go b/backend/internal/adapters/agent/autohand/auth_test.go new file mode 100644 index 000000000..6b0214911 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/auth_test.go @@ -0,0 +1,76 @@ +package autohand + +import ( + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAutohandConfigAuthStatusAuthorized(t *testing.T) { + path := writeAutohandAuthConfig(t, `{ + "auth": {"token": "session-token", "user": {"email": "agent@example.com"}}, + "provider": "zai", + "zai": {"apiKey": "real-provider-key", "model": "glm-5.1"} +}`) + + got, err := autohandConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAutohandConfigAuthStatusUnauthorizedWithMissingCloudToken(t *testing.T) { + path := writeAutohandAuthConfig(t, `{ + "auth": {"token": ""}, + "provider": "zai", + "zai": {"apiKey": "real-provider-key"} +}`) + + got, err := autohandConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusUnauthorized) + } +} + +func TestAutohandConfigAuthStatusAuthorizedWithPlaceholderProviderKey(t *testing.T) { + path := writeAutohandAuthConfig(t, `{ + "auth": {"token": "session-token"}, + "provider": "zai", + "zai": {"apiKey": "api key ", "model": "glm-5.1"} +}`) + + got, err := autohandConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAutohandConfigAuthStatusUnknownWhenMissing(t *testing.T) { + got, err := autohandConfigAuthStatus(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnknown { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusUnknown) + } +} + +func writeAutohandAuthConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/backend/internal/adapters/agent/autohand/install.go b/backend/internal/adapters/agent/autohand/install.go new file mode 100644 index 000000000..62f06f0e3 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/install.go @@ -0,0 +1,8 @@ +package autohand + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.autohandBinary(ctx) +} diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index 6e2b63362..147fec382 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -17,6 +17,7 @@ package claudecode import ( + "bytes" "context" "encoding/json" "fmt" @@ -26,6 +27,7 @@ import ( "runtime" "strings" "sync" + "time" "github.com/google/uuid" @@ -60,6 +62,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 { @@ -269,6 +272,110 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks Claude Code's local authentication state without starting a +// session. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.claudeBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := claudeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "auth", "status").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + if status, ok := claudeAuthStatusFromOutput(out); ok { + return status, nil + } + if err != nil { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +func claudeAuthStatusFromOutput(out []byte) (ports.AgentAuthStatus, bool) { + start := bytes.IndexByte(out, '{') + end := bytes.LastIndexByte(out, '}') + if start < 0 || end < start { + return ports.AgentAuthStatusUnknown, false + } + var status struct { + LoggedIn bool `json:"loggedIn"` + } + if json.Unmarshal(out[start:end+1], &status) != nil { + return ports.AgentAuthStatusUnknown, false + } + if status.LoggedIn { + return ports.AgentAuthStatusAuthorized, true + } + return ports.AgentAuthStatusUnauthorized, true +} + +func claudeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + cfgPath, err := claudeConfigPath() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + return claudeConfigAuthStatus(cfgPath) +} + +func claudeConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + var root map[string]json.RawMessage + if err := json.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + var hasSubscription bool + if raw := root["hasAvailableSubscription"]; len(raw) > 0 { + _ = json.Unmarshal(raw, &hasSubscription) + } + var userID string + if raw := root["userID"]; len(raw) > 0 { + _ = json.Unmarshal(raw, &userID) + } + if strings.TrimSpace(userID) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + var oauthAccount map[string]any + if raw := root["oauthAccount"]; len(raw) > 0 { + if err := json.Unmarshal(raw, &oauthAccount); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + } + if len(oauthAccount) == 0 { + return ports.AgentAuthStatusUnknown, false, nil + } + if hasSubscription { + return ports.AgentAuthStatusAuthorized, true, nil + } + if accountUUID, ok := oauthAccount["accountUuid"].(string); ok && strings.TrimSpace(accountUUID) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + // claudeSessionUUID maps an AO session id onto a stable Claude Code // session UUID via UUIDv5 over a fixed namespace, so the same AO session // always resolves to the same Claude session. diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index 97a1d175d..eb2fc98ba 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -490,6 +490,97 @@ func TestManifestID(t *testing.T) { } } +func TestClaudeConfigAuthStatusAuthorizedWithOAuthSubscription(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{ + "hasAvailableSubscription": true, + "oauthAccount": { + "accountUuid": "account-1", + "subscriptionCreatedAt": "2026-01-01T00:00:00Z" + } + }` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusAuthorizedWithOAuthAccount(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{"oauthAccount":{"accountUuid":"account-1"}}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusAuthorizedWithUserID(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + if err := os.WriteFile(path, []byte(`{"userID":"user-1"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusUnknownWithoutOAuthIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{"oauthAccount":{}}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func TestClaudeAuthStatusFromOutputAuthorizedWithCleanJSON(t *testing.T) { + status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":true,"authMethod":"oauth_token"}`)) + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeAuthStatusFromOutputAuthorizedWithPrefixedWarning(t *testing.T) { + output := []byte("warning: ignored config line\n{\"loggedIn\":true,\"authMethod\":\"oauth_token\"}\n") + status, ok := claudeAuthStatusFromOutput(output) + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeAuthStatusFromOutputUnauthorized(t *testing.T) { + status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":false}`)) + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + func TestEnsureWorkspaceTrustedCreatesEntry(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, ".claude.json") diff --git a/backend/internal/adapters/agent/claudecode/install.go b/backend/internal/adapters/agent/claudecode/install.go new file mode 100644 index 000000000..a809f0c02 --- /dev/null +++ b/backend/internal/adapters/agent/claudecode/install.go @@ -0,0 +1,8 @@ +package claudecode + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.claudeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/cline/auth.go b/backend/internal/adapters/agent/cline/auth.go new file mode 100644 index 000000000..850d86e65 --- /dev/null +++ b/backend/internal/adapters/agent/cline/auth.go @@ -0,0 +1,122 @@ +package cline + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := clineProviderAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +type clineProvidersFile struct { + LastUsedProvider string `json:"lastUsedProvider"` + Providers map[string]clineProvider `json:"providers"` +} + +type clineProvider struct { + Settings clineProviderSettings `json:"settings"` +} + +type clineProviderSettings struct { + APIKey string `json:"apiKey"` + APIKeyAlt string `json:"apikey"` + Auth *clineProviderAuth `json:"auth"` + Provider string `json:"provider"` +} + +type clineProviderAuth struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` +} + +func clineProviderAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + path := filepath.Join(home, ".cline", "data", "settings", "providers.json") + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var file clineProvidersFile + if err := json.Unmarshal(data, &file); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(file.Providers) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + if provider, ok := configuredClineProvider(file); ok { + if providerAuthorized(provider.Settings) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func configuredClineProvider(file clineProvidersFile) (clineProvider, bool) { + if file.LastUsedProvider != "" { + if provider, ok := file.Providers[file.LastUsedProvider]; ok { + return provider, true + } + } + for _, provider := range file.Providers { + return provider, true + } + return clineProvider{}, false +} + +func providerAuthorized(settings clineProviderSettings) bool { + if strings.TrimSpace(settings.APIKey) != "" || strings.TrimSpace(settings.APIKeyAlt) != "" { + return true + } + if settings.Auth == nil { + return false + } + if strings.TrimSpace(settings.Auth.RefreshToken) != "" { + return true + } + if strings.TrimSpace(settings.Auth.AccessToken) == "" { + return false + } + if settings.Auth.ExpiresAt > 0 && settings.Auth.ExpiresAt <= time.Now().UnixMilli() { + return false + } + return true +} diff --git a/backend/internal/adapters/agent/cline/auth_test.go b/backend/internal/adapters/agent/cline/auth_test.go new file mode 100644 index 000000000..4bfbed8f9 --- /dev/null +++ b/backend/internal/adapters/agent/cline/auth_test.go @@ -0,0 +1,118 @@ +package cline + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestClineProviderAuthStatusAuthorizedWithOAuth(t *testing.T) { + writeClineProvidersFile(t, `{ + "version": 1, + "lastUsedProvider": "cline", + "providers": { + "cline": { + "settings": { + "provider": "cline", + "auth": { + "accessToken": "token", + "refreshToken": "refresh", + "expiresAt": `+futureMillis(t)+` + } + } + } + } + }`) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClineProviderAuthStatusAuthorizedWithAPIKey(t *testing.T) { + writeClineProvidersFile(t, `{ + "version": 1, + "lastUsedProvider": "openai", + "providers": { + "openai": { + "settings": { + "provider": "openai", + "apiKey": "sk-test" + } + } + } + }`) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClineProviderAuthStatusUnauthorizedWithExpiredOAuth(t *testing.T) { + writeClineProvidersFile(t, `{ + "version": 1, + "lastUsedProvider": "cline", + "providers": { + "cline": { + "settings": { + "provider": "cline", + "auth": { + "accessToken": "token", + "expiresAt": 1 + } + } + } + } + }`) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestClineProviderAuthStatusUnknownWhenMissing(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func writeClineProvidersFile(t *testing.T, content string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + settingsDir := filepath.Join(home, ".cline", "data", "settings") + if err := os.MkdirAll(settingsDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(settingsDir, "providers.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func futureMillis(t *testing.T) string { + t.Helper() + return strconv.FormatInt(time.Now().Add(time.Hour).UnixMilli(), 10) +} diff --git a/backend/internal/adapters/agent/cline/cline.go b/backend/internal/adapters/agent/cline/cline.go index 512beeb65..9c032f3c0 100644 --- a/backend/internal/adapters/agent/cline/cline.go +++ b/backend/internal/adapters/agent/cline/cline.go @@ -3,9 +3,11 @@ // workspace-local Cline hooks, and reading hook-derived session info. // // Cline is an autonomous coding agent that runs in the terminal (binary -// "cline", installed via `npm i -g cline`). AO drives it headlessly by passing -// the prompt as a positional argument and requesting NDJSON output with -// `--json`, which Cline emits one event per line for machine parsing. +// "cline", installed via `npm i -g cline`). AO drives task launches headlessly +// by passing the prompt as a positional argument and requesting NDJSON output +// with `--json`, which Cline emits one event per line for machine parsing. +// Promptless launches (notably orchestrators) must stay in Cline's interactive +// mode because Cline rejects `--json` without a prompt or piped stdin. // // AO-managed sessions derive native session identity from Cline hooks // (the workspace-local `.clinerules/hooks/` executable scripts AO installs) @@ -67,18 +69,21 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { 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 != "" { @@ -102,9 +107,10 @@ func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.Launch } // GetRestoreCommand rebuilds the argv that continues an existing Cline session: -// `cline --json [approval flags] --id `. ok is false when the -// hook-derived native session id has not landed yet, so callers can fall back -// to fresh launch behavior. +// `cline [approval flags] --id `. Resumes are interactive +// because no prompt is supplied here. ok is false when the hook-derived native +// session id has not landed yet, so callers can fall back to fresh launch +// behavior. func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { if err := ctx.Err(); err != nil { return nil, false, err @@ -120,7 +126,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 diff --git a/backend/internal/adapters/agent/cline/cline_test.go b/backend/internal/adapters/agent/cline/cline_test.go index 7b33121f4..2ef2c6b7c 100644 --- a/backend/internal/adapters/agent/cline/cline_test.go +++ b/backend/internal/adapters/agent/cline/cline_test.go @@ -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 @@ -254,7 +278,6 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { } want := []string{ "cline", - "--json", "--auto-approve", "true", "--id", "session-123", } diff --git a/backend/internal/adapters/agent/cline/install.go b/backend/internal/adapters/agent/cline/install.go new file mode 100644 index 000000000..d3dd2267a --- /dev/null +++ b/backend/internal/adapters/agent/cline/install.go @@ -0,0 +1,8 @@ +package cline + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.clineBinary(ctx) +} diff --git a/backend/internal/adapters/agent/codex/codex.go b/backend/internal/adapters/agent/codex/codex.go index 43d1f6b04..8ce77fbd3 100644 --- a/backend/internal/adapters/agent/codex/codex.go +++ b/backend/internal/adapters/agent/codex/codex.go @@ -13,8 +13,10 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -34,6 +36,7 @@ func New() *Plugin { var _ adapters.Adapter = (*Plugin)(nil) var _ ports.Agent = (*Plugin)(nil) +var _ ports.AgentAuthChecker = (*Plugin)(nil) // Manifest returns the adapter's static self-description. func (p *Plugin) Manifest() adapters.Manifest { @@ -146,10 +149,37 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks Codex's local login state without making a model call. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.codexBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "login", "status").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + text := strings.ToLower(string(out)) + if strings.Contains(text, "not logged in") || strings.Contains(text, "logged out") { + return ports.AgentAuthStatusUnauthorized, nil + } + if strings.Contains(text, "logged in") { + return ports.AgentAuthStatusAuthorized, nil + } + if err != nil { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + // ResolveCodexBinary returns the path to the codex binary on this machine, // searching PATH then a handful of well-known install locations -// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback -// so callers see a clear "command not found" rather than an empty argv. +// (Homebrew, Cargo, npm global, NVM). Returns "codex" as a last-ditch +// fallback so callers see a clear "command not found" rather than an empty +// argv. func ResolveCodexBinary(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -203,6 +233,7 @@ func ResolveCodexBinary(ctx context.Context) (string, error) { filepath.Join(home, ".cargo", "bin", "codex"), filepath.Join(home, ".npm", "bin", "codex"), ) + candidates = append(candidates, nvmNodeBinCandidates(home, "codex")...) } for _, candidate := range candidates { @@ -217,6 +248,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 @@ -328,7 +367,7 @@ func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { } } -func fileExists(path string) bool { +var fileExists = func(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() } diff --git a/backend/internal/adapters/agent/codex/codex_test.go b/backend/internal/adapters/agent/codex/codex_test.go index 0981b2512..e4348f62f 100644 --- a/backend/internal/adapters/agent/codex/codex_test.go +++ b/backend/internal/adapters/agent/codex/codex_test.go @@ -90,6 +90,38 @@ func TestGetLaunchCommandWithoutWorkspaceOmitsTrustFlag(t *testing.T) { } } +func TestResolveCodexBinaryFindsNVMInstallWhenPathIsSparse(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("NVM install discovery is Unix-specific") + } + home := t.TempDir() + binDir := filepath.Join(home, ".nvm", "versions", "node", "v20.19.4", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + want := filepath.Join(binDir, "codex") + if err := os.WriteFile(want, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("PATH", "") + origFileExists := fileExists + fileExists = func(path string) bool { + return strings.HasPrefix(path, home+string(os.PathSeparator)) && origFileExists(path) + } + t.Cleanup(func() { + fileExists = origFileExists + }) + + got, err := ResolveCodexBinary(context.Background()) + if err != nil { + t.Fatalf("ResolveCodexBinary: %v", err) + } + if got != want { + t.Fatalf("ResolveCodexBinary = %q, want %q", got, want) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/adapters/agent/codex/install.go b/backend/internal/adapters/agent/codex/install.go new file mode 100644 index 000000000..94465ab41 --- /dev/null +++ b/backend/internal/adapters/agent/codex/install.go @@ -0,0 +1,8 @@ +package codex + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.codexBinary(ctx) +} diff --git a/backend/internal/adapters/agent/continueagent/auth.go b/backend/internal/adapters/agent/continueagent/auth.go new file mode 100644 index 000000000..e80dc538d --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/auth.go @@ -0,0 +1,79 @@ +package continueagent + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + + yaml "gopkg.in/yaml.v3" +) + +func continueLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("CONTINUE_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + status, ok, err := continueConfigAuthStatus(filepath.Join(home, ".continue", "config.yaml")) + if err != nil || ok { + return status, ok, err + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func continueConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if continueConfigHasCredential(&root) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func continueConfigHasCredential(node *yaml.Node) bool { + if node == nil { + return false + } + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + if continueConfigHasCredential(child) { + return true + } + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := strings.ToLower(strings.TrimSpace(node.Content[i].Value)) + value := strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`) + if (strings.Contains(key, "apikey") || strings.Contains(key, "api_key") || strings.Contains(key, "token")) && + value != "" && + !strings.EqualFold(value, "null") && + !strings.EqualFold(value, "none") { + return true + } + if continueConfigHasCredential(node.Content[i+1]) { + return true + } + } + } + return false +} diff --git a/backend/internal/adapters/agent/continueagent/auth_test.go b/backend/internal/adapters/agent/continueagent/auth_test.go new file mode 100644 index 000000000..6dfe5597e --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/auth_test.go @@ -0,0 +1,50 @@ +package continueagent + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestContinueLocalAuthStatusAuthorizedFromEnv(t *testing.T) { + t.Setenv("CONTINUE_API_KEY", "continue-key") + + status, ok, err := continueLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestContinueLocalAuthStatusUnknownWithoutEnv(t *testing.T) { + t.Setenv("CONTINUE_API_KEY", "") + t.Setenv("HOME", t.TempDir()) + + status, ok, err := continueLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func TestContinueConfigAuthStatusAuthorizedWithAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("models:\n - provider: anthropic\n apiKey: continue-key\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := continueConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} diff --git a/backend/internal/adapters/agent/continueagent/continueagent_test.go b/backend/internal/adapters/agent/continueagent/continueagent_test.go index 1cb146578..b3a23e097 100644 --- a/backend/internal/adapters/agent/continueagent/continueagent_test.go +++ b/backend/internal/adapters/agent/continueagent/continueagent_test.go @@ -29,6 +29,12 @@ func TestManifest(t *testing.T) { } } +func TestDoesNotImplementAuthChecker(t *testing.T) { + if _, ok := any(&Plugin{}).(ports.AgentAuthChecker); ok { + t.Fatal("Continue must not implement AgentAuthChecker; catalog refresh must not run model-call auth probes") + } +} + func TestGetConfigSpecEmpty(t *testing.T) { spec, err := (&Plugin{}).GetConfigSpec(context.Background()) if err != nil { diff --git a/backend/internal/adapters/agent/continueagent/install.go b/backend/internal/adapters/agent/continueagent/install.go new file mode 100644 index 000000000..d9fe23a22 --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/install.go @@ -0,0 +1,8 @@ +package continueagent + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.continueBinary(ctx) +} diff --git a/backend/internal/adapters/agent/copilot/auth.go b/backend/internal/adapters/agent/copilot/auth.go new file mode 100644 index 000000000..6444bbaad --- /dev/null +++ b/backend/internal/adapters/agent/copilot/auth.go @@ -0,0 +1,160 @@ +package copilot + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := copilotLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var copilotTokenEnvVars = []string{ + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", +} + +func copilotLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range copilotTokenEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + configStatus, configOK, err := copilotConfigAuthStatus(filepath.Join(home, ".copilot", "config.json")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if configOK { + return configStatus, true, nil + } + if status, ok, err := copilotSessionStateAuthStatus(ctx, filepath.Join(home, ".copilot", "session-state")); err != nil || ok { + return status, ok, err + } + if status, ok, err := copilotGHAuthStatus(ctx); err != nil || ok { + return status, ok, err + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func copilotConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + text := strings.TrimSpace(string(data)) + if text == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + if textContainsTokenAssignment(text) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func copilotSessionStateAuthStatus(ctx context.Context, dir string) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + paths, err := filepath.Glob(filepath.Join(dir, "*", "events.jsonl")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, path := range paths { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if copilotEventsShowModelUse(string(data)) { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func copilotEventsShowModelUse(text string) bool { + return strings.Contains(text, `"model":`) || + strings.Contains(text, `"type":"tool.execution_complete"`) || + strings.Contains(text, `"type":"message"`) +} + +func copilotGHAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, "gh", "auth", "token").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, false, probeCtx.Err() + } + text := strings.TrimSpace(string(out)) + if err == nil && text != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + if strings.Contains(strings.ToLower(text), "no oauth token") || strings.Contains(strings.ToLower(text), "not logged") { + return ports.AgentAuthStatusUnknown, false, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func textContainsTokenAssignment(text string) bool { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") { + continue + } + lower := strings.ToLower(line) + if !strings.Contains(lower, "token") && !strings.Contains(lower, "auth") { + continue + } + for _, sep := range []string{":", "="} { + _, after, ok := strings.Cut(line, sep) + if !ok { + continue + } + value := strings.Trim(strings.TrimSpace(strings.TrimRight(after, ",")), `"'`) + if value != "" && !strings.EqualFold(value, "null") && !strings.EqualFold(value, "none") { + return true + } + } + } + return false +} diff --git a/backend/internal/adapters/agent/copilot/copilot.go b/backend/internal/adapters/agent/copilot/copilot.go index 5a5d9ee45..3a38de564 100644 --- a/backend/internal/adapters/agent/copilot/copilot.go +++ b/backend/internal/adapters/agent/copilot/copilot.go @@ -6,9 +6,9 @@ // "copilot", installed via npm "@github/copilot"), NOT the older `gh copilot` // suggest/explain extension. // -// Launch runs the CLI in non-interactive ("programmatic") mode with `-p -// ` so it executes the task and exits. Permission modes map onto the -// CLI's allow flags (`--allow-tool`, `--allow-all-tools`, `--allow-all`). +// Launch runs the CLI in interactive mode so AO can keep a durable terminal +// pane attached to the session. Permission modes map onto the CLI's allow flags +// (`--allow-tool`, `--allow-all-tools`, `--allow-all`). // Restore continues an existing session via `--resume `; the // native session id (a UUID under ~/.copilot/session-state/) is captured by the // SessionStart hook AO installs (see hooks.go). @@ -74,13 +74,14 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { return ports.ConfigSpec{}, nil } -// GetLaunchCommand builds the argv to start a new headless Copilot session: +// GetLaunchCommand builds the argv to start a new interactive Copilot session: // -// copilot [permission flags] [-p ] +// copilot [permission flags] // -// The prompt is delivered with `-p`, which runs the prompt in non-interactive -// mode and exits when done. Copilot CLI does not have a documented -// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored. +// The prompt is delivered after the process starts; using `-p` runs Copilot in +// programmatic mode and exits when done, which leaves AO's terminal pane blank +// or dead. Copilot CLI does not have a documented system-prompt-injection flag, +// so SystemPrompt/SystemPromptFile are ignored. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.copilotBinary(ctx) if err != nil { @@ -90,20 +91,16 @@ 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. 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 @@ -194,6 +191,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 +206,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 native := copilotNativeBinaryForLoader(candidate); native != "" { + return native, nil + } return candidate, nil } if err := ctx.Err(); err != nil { @@ -221,6 +225,25 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { return "", fmt.Errorf("copilot: %w", ports.ErrAgentBinaryNotFound) } +func copilotNativeBinaryForLoader(path string) string { + if path == "" || runtime.GOOS == "windows" { + return "" + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil || filepath.Base(resolved) != "npm-loader.js" { + return "" + } + platform := runtime.GOOS + if platform == "darwin" { + platform = "darwin" + } + native := filepath.Join(filepath.Dir(resolved), "node_modules", ".bin", "copilot-"+platform+"-"+runtime.GOARCH) + if fileExists(native) { + return native + } + return "" +} + func (p *Plugin) copilotBinary(ctx context.Context) (string, error) { p.binaryMu.Lock() defer p.binaryMu.Unlock() diff --git a/backend/internal/adapters/agent/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go index d4c4c0c1c..a81b8b520 100644 --- a/backend/internal/adapters/agent/copilot/copilot_test.go +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "testing" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -33,7 +34,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) } @@ -123,7 +124,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { if err != nil { t.Fatal(err) } - if got != ports.PromptDeliveryInCommand { + if got != ports.PromptDeliveryAfterStart { t.Fatalf("unexpected strategy: %q", got) } } @@ -140,6 +141,115 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { } } +func TestCopilotNativeBinaryForNpmLoader(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("npm loader native binary naming is covered on Unix-like platforms") + } + dir := t.TempDir() + packageDir := filepath.Join(dir, "lib", "node_modules", "@github", "copilot") + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(filepath.Join(packageDir, "node_modules", ".bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + loader := filepath.Join(packageDir, "npm-loader.js") + if err := os.WriteFile(loader, []byte("#!/usr/bin/env node\n"), 0o755); err != nil { + t.Fatal(err) + } + native := filepath.Join(packageDir, "node_modules", ".bin", "copilot-"+runtime.GOOS+"-"+runtime.GOARCH) + if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(binDir, "copilot") + if err := os.Symlink(loader, link); err != nil { + t.Fatal(err) + } + + want, err := filepath.EvalSymlinks(native) + if err != nil { + t.Fatal(err) + } + got, err := filepath.EvalSymlinks(copilotNativeBinaryForLoader(link)) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("native binary = %q, want %q", got, want) + } +} + +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + clearCopilotAuthEnv(t) + t.Setenv("GH_TOKEN", "github_pat_test") + plugin := &Plugin{resolvedBinary: "copilot"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestCopilotConfigAuthStatusAuthorizedWithPlainTextToken(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(`{"authToken":"token"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotConfigAuthStatus(configPath) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestCopilotConfigAuthStatusUnauthorizedWithEmptyConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotConfigAuthStatus(configPath) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestCopilotSessionStateAuthStatusAuthorizedWithModelEvent(t *testing.T) { + dir := t.TempDir() + sessionDir := filepath.Join(dir, "session-1") + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, "events.jsonl"), []byte(`{"type":"tool.execution_complete","data":{"model":"claude-sonnet-4.5"}}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotSessionStateAuthStatus(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func clearCopilotAuthEnv(t *testing.T) { + t.Helper() + for _, name := range copilotTokenEnvVars { + t.Setenv(name, "") + } +} + func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { plugin := &Plugin{resolvedBinary: "copilot"} diff --git a/backend/internal/adapters/agent/copilot/install.go b/backend/internal/adapters/agent/copilot/install.go new file mode 100644 index 000000000..67b5e10e4 --- /dev/null +++ b/backend/internal/adapters/agent/copilot/install.go @@ -0,0 +1,8 @@ +package copilot + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.copilotBinary(ctx) +} diff --git a/backend/internal/adapters/agent/crush/auth.go b/backend/internal/adapters/agent/crush/auth.go new file mode 100644 index 000000000..15e58ab6e --- /dev/null +++ b/backend/internal/adapters/agent/crush/auth.go @@ -0,0 +1,84 @@ +package crush + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := crushLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func crushLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + dataDir, ok := crushDataDir() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + return crushProvidersAuthStatus(filepath.Join(dataDir, "providers.json")) +} + +func crushDataDir() (string, bool) { + if dataDir := strings.TrimSpace(os.Getenv("CRUSH_DATA_DIR")); dataDir != "" { + return dataDir, true + } + if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" { + return filepath.Join(dataHome, "crush"), true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".local", "share", "crush"), true +} + +type crushProviderAuth struct { + APIKey string `json:"api_key"` +} + +func crushProvidersAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var providers []crushProviderAuth + if err := json.Unmarshal(data, &providers); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(providers) == 0 { + return ports.AgentAuthStatusUnknown, false, nil + } + for _, provider := range providers { + if strings.TrimSpace(provider.APIKey) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/crush/auth_test.go b/backend/internal/adapters/agent/crush/auth_test.go new file mode 100644 index 000000000..f47f5230b --- /dev/null +++ b/backend/internal/adapters/agent/crush/auth_test.go @@ -0,0 +1,42 @@ +package crush + +import ( + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCrushProvidersAuthStatusAuthorizedWithAPIKey(t *testing.T) { + path := writeCrushProviders(t, `[{"id":"anthropic","api_key":"sk-test"}]`) + + status, ok, err := crushProvidersAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestCrushProvidersAuthStatusUnauthorizedWithEmptyAPIKeys(t *testing.T) { + path := writeCrushProviders(t, `[{"id":"anthropic","api_key":""}]`) + + status, ok, err := crushProvidersAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func writeCrushProviders(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "providers.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/backend/internal/adapters/agent/crush/install.go b/backend/internal/adapters/agent/crush/install.go new file mode 100644 index 000000000..e6d4f041b --- /dev/null +++ b/backend/internal/adapters/agent/crush/install.go @@ -0,0 +1,8 @@ +package crush + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.crushBinary(ctx) +} diff --git a/backend/internal/adapters/agent/cursor/auth.go b/backend/internal/adapters/agent/cursor/auth.go new file mode 100644 index 000000000..487d9ad0a --- /dev/null +++ b/backend/internal/adapters/agent/cursor/auth.go @@ -0,0 +1,113 @@ +package cursor + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, err := cursorCLIAuthStatus(ctx, binary); err == nil && status != ports.AgentAuthStatusUnknown { + return status, nil + } else if err != nil && ctx.Err() != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := cursorLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func cursorCLIAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + return authprobe.CLIStatus(ctx, binary, [][]string{{"status"}}) +} + +func cursorLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return cursorConfigAuthStatus(filepath.Join(home, ".cursor", "cli-config.json")) +} + +func cursorConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + type cursorConfig struct { + AuthInfo struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + UserID any `json:"userId"` + AuthID string `json:"authId"` + } `json:"authInfo"` + } + + var cfgs []cursorConfig + if err := json.Unmarshal(data, &cfgs); err != nil { + var cfg cursorConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + cfgs = []cursorConfig{cfg} + } + for _, cfg := range cfgs { + if cursorConfigHasIdentity(cfg) { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func cursorConfigHasIdentity(cfg struct { + AuthInfo struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + UserID any `json:"userId"` + AuthID string `json:"authId"` + } `json:"authInfo"` +}) bool { + if cfg.AuthInfo.UserID != nil { + switch v := cfg.AuthInfo.UserID.(type) { + case string: + if strings.TrimSpace(v) != "" { + return true + } + default: + return true + } + } + if strings.TrimSpace(cfg.AuthInfo.AuthID) != "" || + strings.TrimSpace(cfg.AuthInfo.Email) != "" || + strings.TrimSpace(cfg.AuthInfo.DisplayName) != "" { + return true + } + return false +} diff --git a/backend/internal/adapters/agent/cursor/auth_test.go b/backend/internal/adapters/agent/cursor/auth_test.go new file mode 100644 index 000000000..5e1539e76 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/auth_test.go @@ -0,0 +1,96 @@ +package cursor + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCursorCLIAuthStatusAuthorizedFromStatus(t *testing.T) { + restore := stubCursorAuthCommand(t, []string{"status"}, []byte("✓ Logged in as user@example.com\n"), nil) + defer restore() + + status, err := cursorCLIAuthStatus(context.Background(), "cursor-agent") + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized) + } +} + +func TestCursorCLIAuthStatusUnknownFromKeychainError(t *testing.T) { + restore := stubCursorAuthCommand(t, []string{"status"}, []byte("ERROR: SecItemCopyMatching failed -50\n"), assertErr("exit status 139")) + defer restore() + + status, err := cursorCLIAuthStatus(context.Background(), "cursor-agent") + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnknown) + } +} + +func TestCursorConfigAuthStatusAuthorizedWithAuthInfo(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli-config.json") + if err := os.WriteFile(path, []byte(`{"authInfo":{"email":"user@example.com","userId":"user-1","authId":"auth-1"}}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := cursorConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestCursorConfigAuthStatusUnknownWithoutAuthInfo(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli-config.json") + if err := os.WriteFile(path, []byte(`{"model":{"modelId":"cursor-default"}}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := cursorConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +type assertErr string + +func (e assertErr) Error() string { + return string(e) +} + +func stubCursorAuthCommand(t *testing.T, wantArgs []string, out []byte, err error) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + if name != "cursor-agent" || !reflect.DeepEqual(arg, wantArgs) { + t.Fatalf("command = %s %#v, want cursor-agent %#v", name, arg, wantArgs) + } + return out, err + } + return func() { authprobe.CmdRunner = previous } +} + +func TestCursorConfigAuthStatusUnknownWhenMissing(t *testing.T) { + status, ok, err := cursorConfigAuthStatus(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} diff --git a/backend/internal/adapters/agent/cursor/install.go b/backend/internal/adapters/agent/cursor/install.go new file mode 100644 index 000000000..eaf9f3ce0 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/install.go @@ -0,0 +1,8 @@ +package cursor + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.cursorBinary(ctx) +} diff --git a/backend/internal/adapters/agent/devin/auth.go b/backend/internal/adapters/agent/devin/auth.go new file mode 100644 index 000000000..0a3940b85 --- /dev/null +++ b/backend/internal/adapters/agent/devin/auth.go @@ -0,0 +1,62 @@ +package devin + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := devinLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, [][]string{{"auth", "status"}}) +} + +func devinLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return devinCredentialsAuthStatus(filepath.Join(home, ".local", "share", "devin", "credentials.toml")) +} + +func devinCredentialsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + text := strings.TrimSpace(string(data)) + if text == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + lower := strings.ToLower(text) + if strings.Contains(lower, "windsurf_api_key") || + strings.Contains(lower, "devin_api_url") || + strings.Contains(lower, "devin_webapp_host") { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} diff --git a/backend/internal/adapters/agent/devin/auth_test.go b/backend/internal/adapters/agent/devin/auth_test.go new file mode 100644 index 000000000..d33b3abee --- /dev/null +++ b/backend/internal/adapters/agent/devin/auth_test.go @@ -0,0 +1,61 @@ +package devin + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusAuthorizedFromAuthStatusOutput(t *testing.T) { + previous := authprobe.CmdRunner + authprobe.CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + if name != "devin" || !reflect.DeepEqual(arg, []string{"auth", "status"}) { + t.Fatalf("command = %s %#v, want devin auth status", name, arg) + } + return []byte("Logged in (via Devin).\n\nUser:\n Email: agentsubs@example.com\n"), nil + } + defer func() { authprobe.CmdRunner = previous }() + + got, err := (&Plugin{resolvedBinary: "devin"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestDevinCredentialsAuthStatusAuthorized(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.toml") + if err := os.WriteFile(path, []byte("windsurf_api_key = \"token\"\ndevin_api_url = \"https://api.devin.ai\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := devinCredentialsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestDevinCredentialsAuthStatusUnauthorizedWithEmptyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.toml") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := devinCredentialsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/devin/install.go b/backend/internal/adapters/agent/devin/install.go new file mode 100644 index 000000000..327c6154a --- /dev/null +++ b/backend/internal/adapters/agent/devin/install.go @@ -0,0 +1,8 @@ +package devin + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.devinBinary(ctx) +} diff --git a/backend/internal/adapters/agent/droid/auth.go b/backend/internal/adapters/agent/droid/auth.go new file mode 100644 index 000000000..b31caa15a --- /dev/null +++ b/backend/internal/adapters/agent/droid/auth.go @@ -0,0 +1,89 @@ +package droid + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if _, err := p.ResolveBinary(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } + status, ok, err := droidLocalAuthStatus(ctx) + if err != nil || ok { + return status, err + } + return ports.AgentAuthStatusUnauthorized, nil +} + +func droidLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("FACTORY_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return droidFactoryAuthStatus(filepath.Join(home, ".factory")) +} + +func droidFactoryAuthStatus(factoryDir string) (ports.AgentAuthStatus, bool, error) { + if factoryDir == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + if fileHasContent(filepath.Join(factoryDir, "auth.v2.file")) && fileHasContent(filepath.Join(factoryDir, "auth.v2.key")) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return droidSettingsAuthStatus(filepath.Join(factoryDir, "settings.json")) +} + +func droidSettingsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + var settings struct { + CustomModels []struct { + Model string `json:"model"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + } `json:"customModels"` + } + if err := json.Unmarshal(data, &settings); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, model := range settings.CustomModels { + if strings.TrimSpace(model.Model) != "" && + strings.TrimSpace(model.BaseURL) != "" && + strings.TrimSpace(model.APIKey) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + if len(settings.CustomModels) > 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func fileHasContent(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() && info.Size() > 0 +} diff --git a/backend/internal/adapters/agent/droid/auth_test.go b/backend/internal/adapters/agent/droid/auth_test.go new file mode 100644 index 000000000..56e777393 --- /dev/null +++ b/backend/internal/adapters/agent/droid/auth_test.go @@ -0,0 +1,72 @@ +package droid + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusAuthorizedFromFactoryAPIKey(t *testing.T) { + t.Setenv("FACTORY_API_KEY", "fk-test") + + got, err := (&Plugin{resolvedBinary: "droid"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestDroidFactoryAuthStatusAuthorizedFromAuthFiles(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "auth.v2.file"), []byte("encrypted auth"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "auth.v2.key"), []byte("key"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := droidFactoryAuthStatus(dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestDroidFactoryAuthStatusAuthorizedFromCustomModelAPIKey(t *testing.T) { + dir := t.TempDir() + settings := `{"customModels":[{"model":"claude-sonnet-4-5-20250929","baseUrl":"https://api.anthropic.com","apiKey":"sk-test"}],"model":"custom:Sonnet-0"}` + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := droidFactoryAuthStatus(dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestDroidFactoryAuthStatusUnauthorizedFromCustomModelWithoutAPIKey(t *testing.T) { + dir := t.TempDir() + settings := `{"customModels":[{"model":"claude-sonnet-4-5-20250929","baseUrl":"https://api.anthropic.com","apiKey":""}]}` + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := droidFactoryAuthStatus(dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/droid/install.go b/backend/internal/adapters/agent/droid/install.go new file mode 100644 index 000000000..b67c2e856 --- /dev/null +++ b/backend/internal/adapters/agent/droid/install.go @@ -0,0 +1,8 @@ +package droid + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.droidBinary(ctx) +} diff --git a/backend/internal/adapters/agent/goose/auth.go b/backend/internal/adapters/agent/goose/auth.go new file mode 100644 index 000000000..76afd2a9f --- /dev/null +++ b/backend/internal/adapters/agent/goose/auth.go @@ -0,0 +1,167 @@ +package goose + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + + yaml "gopkg.in/yaml.v3" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := gooseLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var gooseAPIKeyEnvVars = []string{ + "GOOSE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", +} + +func gooseLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range gooseAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + for _, path := range gooseConfigPaths() { + status, ok, err := gooseAuthStatusFromConfig(path) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if ok { + return status, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func gooseConfigPaths() []string { + seen := map[string]struct{}{} + paths := []string{} + add := func(path string) { + if path == "" { + return + } + if _, ok := seen[path]; ok { + return + } + seen[path] = struct{}{} + paths = append(paths, path) + } + + if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { + add(filepath.Join(xdg, "goose", "config.yaml")) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + // Goose stores config here on macOS as well, rather than under + // os.UserConfigDir's "Application Support" path. + add(filepath.Join(home, ".config", "goose", "config.yaml")) + } + return paths +} + +func gooseAuthStatusFromConfig(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if gooseConfigHasCredential(&root) || gooseConfigHasConfiguredProvider(&root) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func gooseConfigHasCredential(node *yaml.Node) bool { + if node == nil { + return false + } + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + if gooseConfigHasCredential(child) { + return true + } + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := strings.ToLower(strings.TrimSpace(node.Content[i].Value)) + value := strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`) + if (strings.Contains(key, "api_key") || strings.Contains(key, "apikey") || strings.Contains(key, "token")) && + value != "" && + !strings.EqualFold(value, "null") && + !strings.EqualFold(value, "none") { + return true + } + if gooseConfigHasCredential(node.Content[i+1]) { + return true + } + } + } + return false +} + +func gooseConfigHasConfiguredProvider(node *yaml.Node) bool { + if node == nil { + return false + } + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + if gooseConfigHasConfiguredProvider(child) { + return true + } + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := strings.ToLower(strings.TrimSpace(node.Content[i].Value)) + value := strings.ToLower(strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`)) + if key == "configured" && (value == "true" || value == "yes" || value == "1") { + return true + } + if gooseConfigHasConfiguredProvider(node.Content[i+1]) { + return true + } + } + } + return false +} diff --git a/backend/internal/adapters/agent/goose/goose.go b/backend/internal/adapters/agent/goose/goose.go index c0f9c5ab6..faa39963a 100644 --- a/backend/internal/adapters/agent/goose/goose.go +++ b/backend/internal/adapters/agent/goose/goose.go @@ -84,12 +84,15 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { // GetLaunchCommand builds the argv to start a new headless Goose session: // -// [env GOOSE_MODE=] goose run [--system ] [-t ] +// [env GOOSE_MODE=] goose run [--system ] -t // // The prompt is delivered in-command via `-t`. A non-default permission mode is // rendered as an `env GOOSE_MODE=` prefix because Goose reads its approval // mode from the environment, not from a flag. System instructions, when present, -// are passed via `--system`. +// are passed via `--system`. Goose requires one of --instructions, --text, or +// --recipe even when AO intentionally starts a promptless orchestrator, so empty +// prompts are delivered as `-t "" --interactive` to land in an input-ready +// terminal without inventing an initial task. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.gooseBinary(ctx) if err != nil { @@ -106,8 +109,9 @@ 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 diff --git a/backend/internal/adapters/agent/goose/goose_test.go b/backend/internal/adapters/agent/goose/goose_test.go index c5adc2016..8c2da8af1 100644 --- a/backend/internal/adapters/agent/goose/goose_test.go +++ b/backend/internal/adapters/agent/goose/goose_test.go @@ -71,6 +71,22 @@ func TestGetLaunchCommandSystemPromptFileInlined(t *testing.T) { } } +func TestGetLaunchCommandPromptlessLaunchStaysInteractive(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPrompt: "coordinate this project", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"goose", "run", "--system", "coordinate this project", "-t", "", "--interactive"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string @@ -148,6 +164,73 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { } } +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + clearGooseAuthEnv(t) + t.Setenv("OPENROUTER_API_KEY", "test-key") + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusAuthorizedFromGooseConfig(t *testing.T) { + clearGooseAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + configPath := filepath.Join(home, ".config", "goose", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte("providers:\n openrouter:\n configured: true\n model: anthropic/claude-sonnet-4\n"), 0o600); err != nil { + t.Fatal(err) + } + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusUnauthorizedFromEmptyGooseConfig(t *testing.T) { + clearGooseAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + configPath := filepath.Join(home, ".config", "goose", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusUnauthorized) + } +} + +func clearGooseAuthEnv(t *testing.T) { + t.Helper() + for _, name := range gooseAPIKeyEnvVars { + t.Setenv(name, "") + } +} + func TestContextCancellationIsHonored(t *testing.T) { plugin := &Plugin{resolvedBinary: "goose"} ctx, cancel := context.WithCancel(context.Background()) diff --git a/backend/internal/adapters/agent/goose/install.go b/backend/internal/adapters/agent/goose/install.go new file mode 100644 index 000000000..3b3cb45ca --- /dev/null +++ b/backend/internal/adapters/agent/goose/install.go @@ -0,0 +1,8 @@ +package goose + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.gooseBinary(ctx) +} diff --git a/backend/internal/adapters/agent/grok/auth.go b/backend/internal/adapters/agent/grok/auth.go new file mode 100644 index 000000000..3f1fd402a --- /dev/null +++ b/backend/internal/adapters/agent/grok/auth.go @@ -0,0 +1,74 @@ +package grok + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := grokLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func grokLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("GROK_API_KEY")) != "" || strings.TrimSpace(os.Getenv("XAI_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + path := filepath.Join(home, ".grok", "auth.json") + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var entries map[string]json.RawMessage + if err := json.Unmarshal(data, &entries); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(entries) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + for key, value := range entries { + if strings.TrimSpace(key) == "" { + continue + } + trimmed := strings.TrimSpace(string(value)) + if trimmed != "" && trimmed != "null" && trimmed != "{}" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/grok/auth_test.go b/backend/internal/adapters/agent/grok/auth_test.go new file mode 100644 index 000000000..28ec92005 --- /dev/null +++ b/backend/internal/adapters/agent/grok/auth_test.go @@ -0,0 +1,76 @@ +package grok + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGrokLocalAuthStatusAuthorizedWithAPIKeyEnv(t *testing.T) { + t.Setenv("XAI_API_KEY", "xai-test") + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestGrokLocalAuthStatusAuthorizedWithAuthFile(t *testing.T) { + writeGrokAuthFile(t, `{ + "https://auth.x.ai::account": { + "access_token": "token", + "refresh_token": "refresh" + } + }`) + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestGrokLocalAuthStatusUnauthorizedWithEmptyAuthFile(t *testing.T) { + writeGrokAuthFile(t, `{}`) + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestGrokLocalAuthStatusUnknownWhenMissing(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func writeGrokAuthFile(t *testing.T, content string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + grokDir := filepath.Join(home, ".grok") + if err := os.MkdirAll(grokDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(grokDir, "auth.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/adapters/agent/grok/install.go b/backend/internal/adapters/agent/grok/install.go new file mode 100644 index 000000000..2af44fde4 --- /dev/null +++ b/backend/internal/adapters/agent/grok/install.go @@ -0,0 +1,8 @@ +package grok + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.grokBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kilocode/auth.go b/backend/internal/adapters/agent/kilocode/auth.go new file mode 100644 index 000000000..6e42c94c6 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/auth.go @@ -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 +} diff --git a/backend/internal/adapters/agent/kilocode/auth_test.go b/backend/internal/adapters/agent/kilocode/auth_test.go new file mode 100644 index 000000000..90947b345 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/auth_test.go @@ -0,0 +1,165 @@ +package kilocode + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestKilocodeLocalAuthStatusAuthorizedWithProviderEnv(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "openai-key") + + status, ok, err := kilocodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKilocodeAuthListStatusAuthorizedWithEnvironmentVariable(t *testing.T) { + output := ` +log stream error: EPERM: operation not permitted +Credentials ~/.local/share/kilo/auth.json +0 credentials + +Environment +OpenAI OPENAI_API_KEY +1 environment variable +` + status, ok := kilocodeAuthListStatus(output) + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKilocodeAuthListStatusAuthorizedWithCredentials(t *testing.T) { + status, ok := kilocodeAuthListStatus("2 credentials\n0 environment variables") + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKilocodeAuthJSONStatusAuthorizedWithProviderKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(path, []byte(`{"zai":{"type":"api","key":"secret"}}`), 0o600); err != nil { + t.Fatal(err) + } + + authorized, known, err := kilocodeAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !known || !authorized { + t.Fatalf("authorized, known = %v, %v; want true, true", authorized, known) + } +} + +func TestKilocodeAuthJSONStatusUnknownWhenEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(path, []byte(`{"zai":{"type":"api","key":""}}`), 0o600); err != nil { + t.Fatal(err) + } + + authorized, known, err := kilocodeAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !known || authorized { + t.Fatalf("authorized, known = %v, %v; want false, true", authorized, known) + } +} + +func TestKilocodeDBHasAuthorizedAccount(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + CREATE TABLE account_state ( + id integer PRIMARY KEY NOT NULL, + active_account_id text, + active_org_id text + ); + INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) + VALUES ('acct_1', 'user@example.com', 'https://kilo.ai', 'token', 'refresh', 1, 1); + INSERT INTO account_state (id, active_account_id) VALUES (1, 'acct_1'); + `); err != nil { + t.Fatal(err) + } + + authorized, known, err := kilocodeDBHasAuthorizedAccount(context.Background(), db) + if err != nil { + t.Fatal(err) + } + if !known || !authorized { + t.Fatalf("authorized, known = %v, %v; want true, true", authorized, known) + } +} + +func TestAuthStatusUnknownWhenKeyOnlyComesFromInteractiveShell(t *testing.T) { + dir := t.TempDir() + shellPath := filepath.Join(dir, "fake-shell") + if err := os.WriteFile(shellPath, []byte(`#!/bin/sh +/usr/bin/touch "$AO_SHELL_PROBE_MARKER" +if [ "$1" = "-ic" ]; then + OPENAI_API_KEY=from-shell /bin/sh -c "$2" +fi +`), 0o755); err != nil { + t.Fatal(err) + } + kilocodePath := filepath.Join(dir, "kilocode") + if err := os.WriteFile(kilocodePath, []byte(`#!/bin/sh +if [ "$1" = "auth" ] && [ "$2" = "list" ]; then + printf 'auth status unavailable\n' + exit 1 +fi +exit 1 +`), 0o755); err != nil { + t.Fatal(err) + } + markerPath := filepath.Join(dir, "shell-probe-marker") + + t.Setenv("SHELL", shellPath) + t.Setenv("PATH", dir) + t.Setenv("KILO_DATA_DIR", filepath.Join(dir, "missing-kilo-data")) + t.Setenv("AO_SHELL_PROBE_MARKER", markerPath) + for _, name := range kilocodeAPIKeyEnvVars { + t.Setenv(name, "") + } + + status, err := (&Plugin{resolvedBinary: kilocodePath}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnknown) + } + if _, err := os.Stat(markerPath); !os.IsNotExist(err) { + t.Fatalf("interactive shell probe ran; marker stat error = %v", err) + } +} + +func TestKilocodeAuthListStatusUnauthorizedWhenEmpty(t *testing.T) { + status, ok := kilocodeAuthListStatus("0 credentials\n0 environment variables") + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/kilocode/install.go b/backend/internal/adapters/agent/kilocode/install.go new file mode 100644 index 000000000..c2c651602 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/install.go @@ -0,0 +1,8 @@ +package kilocode + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.kilocodeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kimi/auth.go b/backend/internal/adapters/agent/kimi/auth.go new file mode 100644 index 000000000..2ec795eb7 --- /dev/null +++ b/backend/internal/adapters/agent/kimi/auth.go @@ -0,0 +1,88 @@ +package kimi + +import ( + "context" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := kimiLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var kimiAPIKeyEnvVars = []string{ + "KIMI_API_KEY", + "KIMI_CODE_API_KEY", + "MOONSHOT_API_KEY", +} + +func kimiLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range kimiAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + home, ok := kimiCodeHome() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + return kimiConfigAuthStatus(filepath.Join(home, "config.toml")) +} + +func kimiCodeHome() (string, bool) { + if home := strings.TrimSpace(os.Getenv("KIMI_CODE_HOME")); home != "" { + return home, true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".kimi-code"), true +} + +var kimiAPIKeyLineRE = regexp.MustCompile(`(?m)^\s*api_key\s*=\s*("([^"]*)"|'([^']*)'|([^\s#]+))`) + +func kimiConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + matches := kimiAPIKeyLineRE.FindAllStringSubmatch(string(data), -1) + if len(matches) == 0 { + return ports.AgentAuthStatusUnknown, false, nil + } + for _, match := range matches { + for _, group := range match[2:] { + if strings.TrimSpace(group) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/kimi/auth_test.go b/backend/internal/adapters/agent/kimi/auth_test.go new file mode 100644 index 000000000..f060ebeb3 --- /dev/null +++ b/backend/internal/adapters/agent/kimi/auth_test.go @@ -0,0 +1,79 @@ +package kimi + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestKimiLocalAuthStatusAuthorizedWithEnvKey(t *testing.T) { + t.Setenv("KIMI_API_KEY", "kimi-key") + + status, ok, err := kimiLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKimiLocalAuthStatusUsesKimiCodeHome(t *testing.T) { + home := t.TempDir() + t.Setenv("KIMI_CODE_HOME", home) + if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(` +[providers.zai-coding-plan] +type = "openai-compatible" +api_key = "secret" +base_url = "https://api.z.ai/api/coding/paas/v4" +`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := kimiLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKimiConfigAuthStatusAuthorizedWithProviderAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(` +[providers.zai-coding-plan] +api_key = "secret" +`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := kimiConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKimiConfigAuthStatusUnauthorizedWithEmptyAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(` +[providers.zai-coding-plan] +api_key = "" +`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := kimiConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/kimi/install.go b/backend/internal/adapters/agent/kimi/install.go new file mode 100644 index 000000000..a36b10d66 --- /dev/null +++ b/backend/internal/adapters/agent/kimi/install.go @@ -0,0 +1,8 @@ +package kimi + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.kimiBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kiro/auth.go b/backend/internal/adapters/agent/kiro/auth.go new file mode 100644 index 000000000..f68b22b98 --- /dev/null +++ b/backend/internal/adapters/agent/kiro/auth.go @@ -0,0 +1,36 @@ +package kiro + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.kiroBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + return kiroWhoamiAuthStatus(ctx, binary) +} + +func kiroWhoamiAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + out, err := authprobe.CmdRunner(ctx, binary, "whoami") + if ctx.Err() != nil { + return ports.AgentAuthStatusUnknown, ctx.Err() + } + if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} diff --git a/backend/internal/adapters/agent/kiro/install.go b/backend/internal/adapters/agent/kiro/install.go new file mode 100644 index 000000000..4010ea295 --- /dev/null +++ b/backend/internal/adapters/agent/kiro/install.go @@ -0,0 +1,8 @@ +package kiro + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.kiroBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kiro/kiro_test.go b/backend/internal/adapters/agent/kiro/kiro_test.go index 6cd25c9ac..61dfab11a 100644 --- a/backend/internal/adapters/agent/kiro/kiro_test.go +++ b/backend/internal/adapters/agent/kiro/kiro_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -136,6 +137,44 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { } } +func TestAuthStatusUsesKiroWhoami(t *testing.T) { + restore := stubKiroAuthRunner(t, func(_ context.Context, name string, arg ...string) ([]byte, error) { + if name != "kiro-cli" { + t.Fatalf("binary = %q, want kiro-cli", name) + } + if !reflect.DeepEqual(arg, []string{"whoami"}) { + t.Fatalf("args = %#v, want [whoami]", arg) + } + return []byte("Logged in with Google\nEmail: nicachale456@gmail.com\n"), nil + }) + defer restore() + + plugin := &Plugin{resolvedBinary: "kiro-cli"} + status, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusUnauthorizedFromKiroWhoami(t *testing.T) { + restore := stubKiroAuthRunner(t, func(_ context.Context, _ string, _ ...string) ([]byte, error) { + return []byte("Not logged in\n"), nil + }) + defer restore() + + plugin := &Plugin{resolvedBinary: "kiro-cli"} + status, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnauthorized) + } +} + func TestGetAgentHooksInstallsKiroHooks(t *testing.T) { plugin := &Plugin{resolvedBinary: "kiro-cli"} workspace := t.TempDir() @@ -441,6 +480,13 @@ func containsSubsequence(values []string, needle []string) bool { return false } +func stubKiroAuthRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = runner + return func() { authprobe.CmdRunner = previous } +} + func countKiroHookCommand(entries []kiroHookEntry, command string) int { count := 0 for _, entry := range entries { diff --git a/backend/internal/adapters/agent/opencode/install.go b/backend/internal/adapters/agent/opencode/install.go new file mode 100644 index 000000000..b49f37c3f --- /dev/null +++ b/backend/internal/adapters/agent/opencode/install.go @@ -0,0 +1,8 @@ +package opencode + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.opencodeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/opencode/opencode.go b/backend/internal/adapters/agent/opencode/opencode.go index 377f1bde3..415125998 100644 --- a/backend/internal/adapters/agent/opencode/opencode.go +++ b/backend/internal/adapters/agent/opencode/opencode.go @@ -17,15 +17,21 @@ package opencode import ( "context" + "database/sql" + "encoding/json" + "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + + _ "modernc.org/sqlite" // register sqlite driver for opencode session metadata probes ) const ( @@ -55,6 +61,7 @@ func New() *Plugin { var _ adapters.Adapter = (*Plugin)(nil) var _ ports.Agent = (*Plugin)(nil) +var _ ports.AgentAuthChecker = (*Plugin)(nil) // Manifest returns the adapter's static self-description. func (p *Plugin) Manifest() adapters.Manifest { @@ -158,6 +165,187 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks whether opencode has at least one configured provider +// credential. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.opencodeBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := opencodeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "auth", "list").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + text := strings.ToLower(string(out)) + if strings.Contains(text, "0 credentials") { + return ports.AgentAuthStatusUnauthorized, nil + } + if strings.Contains(text, "credential") && err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +var opencodeAPIKeyEnvVars = []string{ + "OPENCODE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", +} + +func opencodeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range opencodeAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + dataDir, ok := opencodeDataDir() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + jsonStatus, jsonOK, err := opencodeAuthJSONStatus(filepath.Join(dataDir, "auth.json")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if jsonOK && jsonStatus == ports.AgentAuthStatusAuthorized { + return jsonStatus, true, nil + } + if status, ok, err := opencodeDBAuthStatus(ctx, filepath.Join(dataDir, "opencode.db")); err != nil || ok { + return status, ok, err + } + if jsonOK { + return jsonStatus, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func opencodeDataDir() (string, bool) { + if dataDir := strings.TrimSpace(os.Getenv("OPENCODE_DATA_DIR")); dataDir != "" { + return dataDir, true + } + if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" { + return filepath.Join(dataHome, "opencode"), true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".local", "share", "opencode"), true +} + +func opencodeAuthJSONStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var entries map[string]json.RawMessage + if err := json.Unmarshal(data, &entries); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(entries) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + for key, value := range entries { + if strings.TrimSpace(key) == "" { + continue + } + trimmed := strings.TrimSpace(string(value)) + if trimmed != "" && trimmed != "null" && trimmed != "{}" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} + +func opencodeDBAuthStatus(ctx context.Context, path string) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } else if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro&_pragma=busy_timeout(1000)") + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + defer func() { + _ = db.Close() + }() + + authorized, known, err := opencodeDBHasAuthorizedAccount(ctx, db) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if !known { + return ports.AgentAuthStatusUnknown, false, nil + } + if authorized { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil +} + +func opencodeDBHasAuthorizedAccount(ctx context.Context, db *sql.DB) (authorized, known bool, err error) { + for _, query := range []string{ + `SELECT COUNT(*) FROM account_state WHERE active_account_id IS NOT NULL AND trim(active_account_id) != ''`, + `SELECT COUNT(*) FROM account WHERE trim(access_token) != ''`, + `SELECT COUNT(*) FROM control_account WHERE active = 1 AND trim(access_token) != ''`, + } { + count, err := opencodeDBCount(ctx, db, query) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "no such table") { + continue + } + return false, false, err + } + known = true + if count > 0 { + return true, true, nil + } + } + return false, known, nil +} + +func opencodeDBCount(ctx context.Context, db *sql.DB, query string) (int, error) { + var count int + if err := db.QueryRowContext(ctx, query).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + // appendPermissionFlags maps AO's permission modes onto opencode's single // approval flag. opencode exposes only --dangerously-skip-permissions (no // graduated accept-edits/auto modes), so: @@ -185,9 +373,7 @@ func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { // ResolveOpenCodeBinary returns the path to the opencode binary on this machine, // searching PATH then a handful of well-known install locations (the install -// script's ~/.opencode/bin, Homebrew, npm global). Returns "opencode" as a -// last-ditch fallback so callers see a clear "command not found" rather than an -// empty argv. +// script's ~/.opencode/bin, Homebrew, npm global). func ResolveOpenCodeBinary(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -211,7 +397,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) { return candidate, nil } } - return "opencode", nil + return "", fmt.Errorf("opencode: %w", ports.ErrAgentBinaryNotFound) } if path, err := exec.LookPath("opencode"); err == nil && path != "" { @@ -238,7 +424,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) { } } - return "opencode", nil + return "", fmt.Errorf("opencode: %w", ports.ErrAgentBinaryNotFound) } func (p *Plugin) opencodeBinary(ctx context.Context) (string, error) { diff --git a/backend/internal/adapters/agent/opencode/opencode_test.go b/backend/internal/adapters/agent/opencode/opencode_test.go index ba73297c1..20a19326d 100644 --- a/backend/internal/adapters/agent/opencode/opencode_test.go +++ b/backend/internal/adapters/agent/opencode/opencode_test.go @@ -2,6 +2,8 @@ package opencode import ( "context" + "database/sql" + "errors" "os" "path/filepath" "reflect" @@ -11,6 +13,279 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) +func TestOpenCodeLocalAuthStatusAuthorizedWithEnv(t *testing.T) { + clearOpenCodeAuthEnv(t) + t.Setenv("ANTHROPIC_API_KEY", "sk-ant-test") + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusAuthorizedWithAuthFile(t *testing.T) { + clearOpenCodeAuthEnv(t) + writeOpenCodeAuthFile(t, `{ + "anthropic": { + "type": "api", + "key": "sk-ant-test" + } + }`) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusUnauthorizedWithEmptyAuthFile(t *testing.T) { + clearOpenCodeAuthEnv(t) + writeOpenCodeAuthFile(t, `{}`) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestOpenCodeLocalAuthStatusAuthorizedWithActiveDBAccount(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataDir := writeOpenCodeDB(t, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + CREATE TABLE account_state ( + id integer PRIMARY KEY NOT NULL, + active_account_id text, + active_org_id text + ); + INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) + VALUES ('acct_1', 'user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1); + INSERT INTO account_state (id, active_account_id) VALUES (1, 'acct_1'); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("OPENCODE_DATA_DIR", dataDir) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusDBAccountOverridesEmptyAuthFile(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataDir := writeOpenCodeDB(t, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) + VALUES ('acct_1', 'user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("OPENCODE_DATA_DIR", dataDir) + if err := os.WriteFile(filepath.Join(dataDir, "auth.json"), []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusAuthorizedWithControlDBAccount(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataHome := t.TempDir() + dataDir := filepath.Join(dataHome, "opencode") + writeOpenCodeDBAt(t, dataDir, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE control_account ( + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + active integer NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + PRIMARY KEY(email, url) + ); + INSERT INTO control_account (email, url, access_token, refresh_token, active, time_created, time_updated) + VALUES ('user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1, 1); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("XDG_DATA_HOME", dataHome) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusUnauthorizedWithEmptyDBAccounts(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataDir := writeOpenCodeDB(t, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + CREATE TABLE account_state ( + id integer PRIMARY KEY NOT NULL, + active_account_id text, + active_org_id text + ); + CREATE TABLE control_account ( + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + active integer NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + PRIMARY KEY(email, url) + ); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("OPENCODE_DATA_DIR", dataDir) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestOpenCodeLocalAuthStatusUnknownWhenMissing(t *testing.T) { + clearOpenCodeAuthEnv(t) + t.Setenv("HOME", t.TempDir()) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func writeOpenCodeDB(t *testing.T, setup func(*sql.DB)) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + dataDir := filepath.Join(home, ".local", "share", "opencode") + writeOpenCodeDBAt(t, dataDir, setup) + return dataDir +} + +func writeOpenCodeDBAt(t *testing.T, dataDir string, setup func(*sql.DB)) { + t.Helper() + if err := os.MkdirAll(dataDir, 0o700); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(filepath.Join(dataDir, "opencode.db"))+"?mode=rwc") + if err != nil { + t.Fatal(err) + } + defer db.Close() + setup(db) +} + +func writeOpenCodeAuthFile(t *testing.T, content string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + authDir := filepath.Join(home, ".local", "share", "opencode") + if err := os.MkdirAll(authDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(authDir, "auth.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func clearOpenCodeAuthEnv(t *testing.T) { + t.Helper() + for _, name := range opencodeAPIKeyEnvVars { + t.Setenv(name, "") + } + t.Setenv("OPENCODE_DATA_DIR", "") + t.Setenv("XDG_DATA_HOME", "") +} + +func TestResolveOpenCodeBinaryFallback(t *testing.T) { + bin, err := ResolveOpenCodeBinary(context.Background()) + if err != nil { + if !errors.Is(err, ports.ErrAgentBinaryNotFound) { + t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err) + } + return + } + if bin == "" { + t.Fatal("ResolveOpenCodeBinary returned empty path with no error") + } +} + +func TestResolveOpenCodeBinaryContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := ResolveOpenCodeBinary(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("ResolveOpenCodeBinary err = %v, want context.Canceled", err) + } +} + func TestGetLaunchCommandBuildsArgv(t *testing.T) { plugin := &Plugin{resolvedBinary: "opencode"} diff --git a/backend/internal/adapters/agent/pi/auth.go b/backend/internal/adapters/agent/pi/auth.go new file mode 100644 index 000000000..035bf3a50 --- /dev/null +++ b/backend/internal/adapters/agent/pi/auth.go @@ -0,0 +1,85 @@ +package pi + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := piLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func piLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + configDir, ok := piConfigDir() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + return piAuthJSONStatus(filepath.Join(configDir, "auth.json")) +} + +func piConfigDir() (string, bool) { + if configDir := strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")); configDir != "" { + return configDir, true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".pi", "agent"), true +} + +type piAuthEntry struct { + Type string `json:"type"` + Key string `json:"key"` +} + +func piAuthJSONStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var entries map[string]piAuthEntry + if err := json.Unmarshal(data, &entries); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(entries) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + for provider, entry := range entries { + if strings.TrimSpace(provider) == "" { + continue + } + if strings.TrimSpace(entry.Key) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/pi/auth_test.go b/backend/internal/adapters/agent/pi/auth_test.go new file mode 100644 index 000000000..3a04737e4 --- /dev/null +++ b/backend/internal/adapters/agent/pi/auth_test.go @@ -0,0 +1,42 @@ +package pi + +import ( + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestPiAuthJSONStatusAuthorizedWithProviderKey(t *testing.T) { + path := writePiAuthJSON(t, `{"zai":{"type":"api_key","key":"test-key"}}`) + + status, ok, err := piAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestPiAuthJSONStatusUnauthorizedWhenEmpty(t *testing.T) { + path := writePiAuthJSON(t, `{}`) + + status, ok, err := piAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func writePiAuthJSON(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/backend/internal/adapters/agent/pi/install.go b/backend/internal/adapters/agent/pi/install.go new file mode 100644 index 000000000..54130bbef --- /dev/null +++ b/backend/internal/adapters/agent/pi/install.go @@ -0,0 +1,8 @@ +package pi + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.piBinary(ctx) +} diff --git a/backend/internal/adapters/agent/qwen/auth.go b/backend/internal/adapters/agent/qwen/auth.go new file mode 100644 index 000000000..7f69a5a9e --- /dev/null +++ b/backend/internal/adapters/agent/qwen/auth.go @@ -0,0 +1,116 @@ +package qwen + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := qwenLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var qwenAPIKeyEnvVars = []string{ + "QWEN_API_KEY", + "BAILIAN_CODING_PLAN_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "REQUESTY_API_KEY", + "DASHSCOPE_API_KEY", + "ZAI_API_KEY", +} + +func qwenLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range qwenAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return qwenAuthStatusFromSettings(filepath.Join(home, ".qwen", "settings.json")) +} + +func qwenAuthStatusFromSettings(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var root any + if err := json.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if containsQwenAPIKey(root) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func containsQwenAPIKey(value any) bool { + switch v := value.(type) { + case map[string]any: + for key, child := range v { + if strings.EqualFold(key, "apiKey") || strings.EqualFold(key, "apikey") { + if stringSetting(child) != "" { + return true + } + continue + } + if containsQwenAPIKey(child) { + return true + } + } + case []any: + for _, child := range v { + if containsQwenAPIKey(child) { + return true + } + } + } + return false +} + +func stringSetting(value any) string { + text, ok := value.(string) + if !ok { + return "" + } + text = strings.TrimSpace(text) + if text == "" || strings.EqualFold(text, "null") || strings.EqualFold(text, "none") { + return "" + } + return text +} diff --git a/backend/internal/adapters/agent/qwen/auth_test.go b/backend/internal/adapters/agent/qwen/auth_test.go new file mode 100644 index 000000000..1fe791325 --- /dev/null +++ b/backend/internal/adapters/agent/qwen/auth_test.go @@ -0,0 +1,78 @@ +package qwen + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestQwenLocalAuthStatusAuthorizedWithProviderEnv(t *testing.T) { + t.Setenv("ZAI_API_KEY", "zai-key") + + status, ok, err := qwenLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestQwenAuthStatusFromSettingsAuthorizedWithModelProviderAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + content := `{ + "modelProviders": { + "zai": { + "baseUrl": "https://api.z.ai/api/coding/paas/v4", + "apiKey": "zai-key" + } + }, + "defaultModel": "glm-4.5" + }` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := qwenAuthStatusFromSettings(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestQwenAuthStatusFromSettingsAuthorizedWithSecurityAuthAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + content := `{ + "security": { + "auth": { + "apiKey": "openai-compatible-key" + } + } + }` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := qwenAuthStatusFromSettings(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestQwenAuthStatusFromSettingsUnknownWhenMissing(t *testing.T) { + status, ok, err := qwenAuthStatusFromSettings(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} diff --git a/backend/internal/adapters/agent/qwen/install.go b/backend/internal/adapters/agent/qwen/install.go new file mode 100644 index 000000000..e54ef99dc --- /dev/null +++ b/backend/internal/adapters/agent/qwen/install.go @@ -0,0 +1,8 @@ +package qwen + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.qwenBinary(ctx) +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index 77f9b5264..91141e9d9 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -83,8 +83,9 @@ func Build() (*adapters.Registry, error) { // harness is the adapter's manifest id, which is also the domain.AgentHarness // value a session carries and the `--harness` flag users pass. type HarnessAgent struct { - Harness domain.AgentHarness - Agent ports.Agent + Harness domain.AgentHarness + Manifest adapters.Manifest + Agent ports.Agent } // Harnessed returns every shipped adapter that drives an agent, paired with its @@ -99,8 +100,9 @@ func Harnessed() []HarnessAgent { continue } out = append(out, HarnessAgent{ - Harness: domain.AgentHarness(a.Manifest().ID), - Agent: agent, + Harness: domain.AgentHarness(a.Manifest().ID), + Manifest: a.Manifest(), + Agent: agent, }) } return out diff --git a/backend/internal/adapters/agent/registry/registry_test.go b/backend/internal/adapters/agent/registry/registry_test.go index 269abced9..25cef3eed 100644 --- a/backend/internal/adapters/agent/registry/registry_test.go +++ b/backend/internal/adapters/agent/registry/registry_test.go @@ -23,6 +23,9 @@ func TestGetAgentHooksFootprintIsGitignored(t *testing.T) { for _, ha := range Harnessed() { t.Run(string(ha.Harness), func(t *testing.T) { ws := t.TempDir() + if ha.Harness == "autohand" { + t.Setenv("AUTOHAND_CONFIG", filepath.Join(t.TempDir(), "config.json")) + } cfg := ports.WorkspaceHookConfig{ SessionID: "proj-1", WorkspacePath: ws, @@ -52,6 +55,23 @@ func TestGetAgentHooksFootprintIsGitignored(t *testing.T) { } } +func TestEveryHarnessReportsAuthStatus(t *testing.T) { + authCheckerExempt := map[string]string{ + "continue": "Continue auth probes require sending a model prompt, so catalog refresh must not run them", + } + for _, ha := range Harnessed() { + if reason, exempt := authCheckerExempt[string(ha.Harness)]; exempt { + if _, ok := ha.Agent.(ports.AgentAuthChecker); ok { + t.Errorf("%s implements ports.AgentAuthChecker but is exempt: %s", ha.Harness, reason) + } + continue + } + if _, ok := ha.Agent.(ports.AgentAuthChecker); !ok { + t.Errorf("%s does not implement ports.AgentAuthChecker", ha.Harness) + } + } +} + // workspaceFiles returns every regular file under root, relative to root. func workspaceFiles(t *testing.T, root string) []string { t.Helper() diff --git a/backend/internal/adapters/agent/vibe/auth.go b/backend/internal/adapters/agent/vibe/auth.go new file mode 100644 index 000000000..b05a6d186 --- /dev/null +++ b/backend/internal/adapters/agent/vibe/auth.go @@ -0,0 +1,181 @@ +package vibe + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := vibeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +const ( + // This names the default env var Vibe reads; it is not a credential value. + vibeDefaultAPIKeyEnvVar = "MISTRAL_API_KEY" //nolint:gosec // env var name, not a credential value + vibeKeychainService = "vibe" +) + +func vibeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + vibeHome := os.Getenv("VIBE_HOME") + if strings.TrimSpace(vibeHome) == "" { + vibeHome = filepath.Join(home, ".vibe") + } + + envVars, err := vibeAPIKeyEnvVars(filepath.Join(vibeHome, "config.toml")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, envVar := range envVars { + if strings.TrimSpace(os.Getenv(envVar)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + if status, ok, err := vibeEnvFileAuthStatus(filepath.Join(vibeHome, ".env"), envVar); err != nil || ok { + return status, ok, err + } + } + if status, ok, err := vibeSessionLogAuthStatus(ctx, filepath.Join(vibeHome, "logs", "session")); err != nil || ok { + return status, ok, err + } + for _, envVar := range envVars { + if status, ok, err := vibeKeychainAuthStatus(ctx, envVar); err != nil || ok { + return status, ok, err + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeAPIKeyEnvVars(configPath string) ([]string, error) { + vars := []string{vibeDefaultAPIKeyEnvVar, "VIBE_CODE_API_KEY"} + data, err := os.ReadFile(configPath) + if os.IsNotExist(err) { + return vars, nil + } + if err != nil { + return nil, err + } + for _, line := range strings.Split(string(data), "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok || strings.TrimSpace(key) != "api_key_env_var" { + continue + } + envVar := strings.Trim(strings.TrimSpace(value), `"',`) + if envVar != "" && !strings.EqualFold(envVar, "null") && !containsString(vars, envVar) { + vars = append(vars, envVar) + } + } + return vars, nil +} + +func vibeEnvFileAuthStatus(path, envVar string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(key) != envVar { + continue + } + if strings.Trim(strings.TrimSpace(value), `"'`) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeKeychainAuthStatus(ctx context.Context, envVar string) (ports.AgentAuthStatus, bool, error) { + if strings.TrimSpace(envVar) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + //nolint:gosec // invokes macOS security with fixed command and validated account/service arguments + out, err := exec.CommandContext(probeCtx, "security", "find-generic-password", "-s", vibeKeychainService, "-a", envVar, "-w").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, false, nil + } + if err == nil && strings.TrimSpace(string(out)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeSessionLogAuthStatus(ctx context.Context, dir string) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + paths, err := filepath.Glob(filepath.Join(dir, "session_*", "messages.jsonl")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, path := range paths { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if vibeMessagesShowModelUse(string(data)) { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeMessagesShowModelUse(text string) bool { + return strings.Contains(text, `"role": "assistant"`) || + strings.Contains(text, `"role":"assistant"`) || + strings.Contains(text, `"reasoning_content"`) || + strings.Contains(text, `"session_completion_tokens"`) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/vibe/install.go b/backend/internal/adapters/agent/vibe/install.go new file mode 100644 index 000000000..3ed8bb0c9 --- /dev/null +++ b/backend/internal/adapters/agent/vibe/install.go @@ -0,0 +1,8 @@ +package vibe + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.vibeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/vibe/vibe.go b/backend/internal/adapters/agent/vibe/vibe.go index a838349ac..d002a5e57 100644 --- a/backend/internal/adapters/agent/vibe/vibe.go +++ b/backend/internal/adapters/agent/vibe/vibe.go @@ -78,12 +78,14 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { // GetLaunchCommand builds the argv to start a new non-interactive Vibe session: // -// vibe --trust --output text [--agent ] -p +// vibe --trust --output text [--workdir ] [--agent ] -p // // The prompt is delivered through `-p` (programmatic mode), so AO uses // in-command delivery. `--trust` skips the trust prompt for automation and -// `--output text` pins the output format. Vibe exposes no CLI system-prompt -// flag (system prompts are config-driven), so SystemPrompt is not forwarded. +// `--output text` pins the output format. `--workdir` is passed explicitly +// because Vibe validates its own working directory in addition to the process +// cwd AO sets through the runtime. Vibe exposes no CLI system-prompt flag +// (system prompts are config-driven), so SystemPrompt is not forwarded. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { if err := ctx.Err(); err != nil { return nil, err @@ -94,6 +96,7 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( } cmd = []string{binary, "--trust", "--output", "text"} + appendWorkdirFlag(&cmd, cfg.WorkspacePath) appendAgentFlags(&cmd, cfg.Permissions) if cfg.Prompt != "" { cmd = append(cmd, "-p", cfg.Prompt) @@ -134,6 +137,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) } cmd = make([]string, 0, 8) cmd = append(cmd, binary, "--trust", "--output", "text") + appendWorkdirFlag(&cmd, cfg.Session.WorkspacePath) appendAgentFlags(&cmd, cfg.Permissions) cmd = append(cmd, "--resume", agentSessionID) return cmd, true, nil @@ -148,6 +152,12 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return ports.SessionInfo{}, false, nil } +func appendWorkdirFlag(cmd *[]string, workspacePath string) { + if workspacePath != "" { + *cmd = append(*cmd, "--workdir", workspacePath) + } +} + // appendAgentFlags maps AO permission modes onto Vibe's builtin `--agent` // profiles. PermissionModeDefault (and the empty mode) emit no flag so Vibe // resolves its starting agent from the user's `default_agent` config. diff --git a/backend/internal/adapters/agent/vibe/vibe_test.go b/backend/internal/adapters/agent/vibe/vibe_test.go index 06d8deef0..baae1024d 100644 --- a/backend/internal/adapters/agent/vibe/vibe_test.go +++ b/backend/internal/adapters/agent/vibe/vibe_test.go @@ -3,6 +3,8 @@ package vibe import ( "context" "errors" + "os" + "path/filepath" "reflect" "testing" @@ -39,6 +41,91 @@ func TestGetConfigSpecEmpty(t *testing.T) { } } +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + clearVibeAuthEnv(t, vibeDefaultAPIKeyEnvVar, "VIBE_CODE_API_KEY") + t.Setenv(vibeDefaultAPIKeyEnvVar, "test-key") + p := &Plugin{resolvedBinary: "vibe"} + + got, err := p.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestVibeAPIKeyEnvVarsReadsConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(configPath, []byte("[[providers]]\napi_key_env_var = \"CUSTOM_VIBE_KEY\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + got, err := vibeAPIKeyEnvVars(configPath) + if err != nil { + t.Fatal(err) + } + if !containsString(got, vibeDefaultAPIKeyEnvVar) || !containsString(got, "CUSTOM_VIBE_KEY") { + t.Fatalf("vibeAPIKeyEnvVars = %#v, want default and custom key", got) + } +} + +func TestVibeEnvFileAuthStatusAuthorized(t *testing.T) { + envPath := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(envPath, []byte("MISTRAL_API_KEY=test-key\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := vibeEnvFileAuthStatus(envPath, vibeDefaultAPIKeyEnvVar) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestVibeEnvFileAuthStatusUnauthorizedForEmptyValue(t *testing.T) { + envPath := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(envPath, []byte("MISTRAL_API_KEY=\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := vibeEnvFileAuthStatus(envPath, vibeDefaultAPIKeyEnvVar) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestVibeSessionLogAuthStatusAuthorizedWithAssistantMessage(t *testing.T) { + dir := t.TempDir() + sessionDir := filepath.Join(dir, "session_20260625_071829_d5e8a6eb") + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, "messages.jsonl"), []byte(`{"role":"assistant","content":"Hello"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := vibeSessionLogAuthStatus(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func clearVibeAuthEnv(t *testing.T, names ...string) { + t.Helper() + for _, name := range names { + t.Setenv(name, "") + } +} + func TestGetPromptDeliveryStrategy(t *testing.T) { s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -52,14 +139,15 @@ func TestGetPromptDeliveryStrategy(t *testing.T) { func TestGetLaunchCommandWithPrompt(t *testing.T) { p := &Plugin{resolvedBinary: "vibe"} cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ - Permissions: ports.PermissionModeBypassPermissions, - Prompt: "add a health check", + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "add a health check", + WorkspacePath: "/work/repo", }) if err != nil { t.Fatal(err) } - want := []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve", "-p", "add a health check"} + want := []string{"vibe", "--trust", "--output", "text", "--workdir", "/work/repo", "--agent", "auto-approve", "-p", "add a health check"} if !reflect.DeepEqual(cmd, want) { t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) } @@ -124,7 +212,8 @@ func TestGetRestoreCommand(t *testing.T) { p := &Plugin{resolvedBinary: "vibe"} cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ Session: ports.SessionRef{ - Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abcd1234-5678-90ab-cdef-1234567890ab"}, + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abcd1234-5678-90ab-cdef-1234567890ab"}, + WorkspacePath: "/work/repo", }, Permissions: ports.PermissionModeBypassPermissions, }) @@ -135,7 +224,7 @@ func TestGetRestoreCommand(t *testing.T) { t.Fatal("ok=false, want true") } - want := []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve", "--resume", "abcd1234-5678-90ab-cdef-1234567890ab"} + want := []string{"vibe", "--trust", "--output", "text", "--workdir", "/work/repo", "--agent", "auto-approve", "--resume", "abcd1234-5678-90ab-cdef-1234567890ab"} if !reflect.DeepEqual(cmd, want) { t.Fatalf("cmd = %#v, want %#v", cmd, want) } diff --git a/backend/internal/cli/stop_test.go b/backend/internal/cli/stop_test.go index 0a150d633..9a298ecae 100644 --- a/backend/internal/cli/stop_test.go +++ b/backend/internal/cli/stop_test.go @@ -43,6 +43,7 @@ func TestWaitForStoppedKeepsRunFileFromConcurrentStart(t *testing.T) { } if info == nil { t.Fatal("new daemon's run-file was deleted by stop of a different PID") + return } if info.PID != newPID { t.Fatalf("run-file PID = %d, want %d (new daemon)", info.PID, newPID) diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index ab7c2b2eb..727e0a86f 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -16,11 +16,13 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/daemon/supervisor" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/notify" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/preview" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer" notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" @@ -132,7 +134,8 @@ func Run() error { previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx) srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ - Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, Telemetry: telemetrySink}), + Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}), + Agents: agentsvc.New(), Sessions: sessionSvc, Reviews: reviewSvc, Notifications: notifier, diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 3ec736911..218e6815b 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -19,6 +19,7 @@ import ( // APIDeps bundles every service the API layer's controllers depend on. type APIDeps struct { + Agents controllers.AgentCatalog Projects projectsvc.Manager Sessions controllers.SessionService Activity controllers.ActivityRecorder @@ -36,6 +37,7 @@ type APIDeps struct { // router invokes to mount the /api/v1 surface. type API struct { cfg config.Config + agents *controllers.AgentsController projects *controllers.ProjectsController sessions *controllers.SessionsController prs *controllers.PRsController @@ -51,6 +53,9 @@ type API struct { func NewAPI(cfg config.Config, deps APIDeps) *API { return &API{ cfg: cfg, + agents: &controllers.AgentsController{ + Catalog: deps.Agents, + }, projects: &controllers.ProjectsController{ Mgr: deps.Projects, }, @@ -80,6 +85,7 @@ func (a *API) Register(root chi.Router) { r.Group(func(r chi.Router) { r.Use(middleware.Timeout(timeout)) + a.agents.Register(r) a.projects.Register(r) a.sessions.Register(r) a.prs.Register(r) diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 2d7e7bf46..d3c628a46 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -8,6 +8,56 @@ servers: - description: Local daemon (loopback only) url: http://127.0.0.1:3001 paths: + /api/v1/agents: + get: + operationId: listAgents + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListAgentsResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Return cached supported and locally installed agent adapters + tags: + - agents + /api/v1/agents/refresh: + post: + operationId: refreshAgents + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListAgentsResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Refresh the cached local agent adapter catalog + tags: + - agents /api/v1/events: get: operationId: streamEvents @@ -1486,6 +1536,24 @@ components: permissions: type: string type: object + AgentInfo: + properties: + authStatus: + description: Advisory local auth probe result. authorized means a recent + local probe passed; spawn remains the authoritative validation point. + enum: + - authorized + - unauthorized + - unknown + type: string + id: + type: string + label: + type: string + required: + - id + - label + type: object ClaimPRRequest: properties: allowTakeover: @@ -1695,6 +1763,31 @@ components: - ok - sessionId type: object + ListAgentsResponse: + properties: + authorized: + description: Compatibility list of installed agents whose local auth probe + recently returned authorized. Advisory and stale-prone; spawn may still + fail. + items: + $ref: '#/components/schemas/AgentInfo' + type: array + installed: + description: Agents whose binary resolved during the latest best-effort + local catalog probe. + items: + $ref: '#/components/schemas/AgentInfo' + type: array + supported: + description: Agents supported by this daemon build. + items: + $ref: '#/components/schemas/AgentInfo' + type: array + required: + - supported + - installed + - authorized + type: object ListNotificationsResponse: properties: notifications: @@ -2587,6 +2680,8 @@ components: - repo type: object tags: +- description: Supported and locally runnable agent adapters + name: agents - description: Project registry, configuration, and lifecycle administration name: projects - description: Agent session lifecycle and messaging diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 564328684..9e3d0634a 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -55,6 +55,8 @@ func Build() ([]byte, error) { *(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"), } r.Spec.Tags = []openapi31.Tag{ + *(&openapi31.Tag{Name: "agents"}).WithDescription( + "Supported and locally runnable agent adapters"), *(&openapi31.Tag{Name: "projects"}).WithDescription( "Project registry, configuration, and lifecycle administration"), *(&openapi31.Tag{Name: "sessions"}).WithDescription( @@ -170,6 +172,8 @@ var schemaNames = map[string]string{ "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", "ControllersOrchestratorResponse": "OrchestratorResponse", + "AgentInventory": "ListAgentsResponse", + "AgentInfo": "AgentInfo", "ControllersListNotificationsQuery": "ListNotificationsQuery", "ControllersNotificationStreamQuery": "NotificationStreamQuery", "ControllersNotificationIDParam": "NotificationIDParam", @@ -279,6 +283,7 @@ type operation struct { func operations() []operation { ops := append([]operation{}, eventOperations()...) + ops = append(ops, agentOperations()...) ops = append(ops, projectOperations()...) ops = append(ops, sessionOperations()...) ops = append(ops, prOperations()...) @@ -288,6 +293,29 @@ func operations() []operation { return ops } +func agentOperations() []operation { + return []operation{ + { + method: http.MethodGet, path: "/api/v1/agents", id: "listAgents", tag: "agents", + summary: "Return cached supported and locally installed agent adapters", + resps: []respUnit{ + {http.StatusOK, controllers.ListAgentsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/agents/refresh", id: "refreshAgents", tag: "agents", + summary: "Refresh the cached local agent adapter catalog", + resps: []respUnit{ + {http.StatusOK, controllers.RefreshAgentsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + } +} + // importOperations declares the 2 /import operations. Must stay 1:1 with // the routes ImportController.Register mounts (enforced by the parity test). func importOperations() []operation { diff --git a/backend/internal/httpd/controllers/agents.go b/backend/internal/httpd/controllers/agents.go new file mode 100644 index 000000000..d8c3624e3 --- /dev/null +++ b/backend/internal/httpd/controllers/agents.go @@ -0,0 +1,55 @@ +package controllers + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" +) + +// AgentCatalog is the controller-facing contract for local agent inventory. +type AgentCatalog interface { + List(ctx context.Context) (agentsvc.Inventory, error) + Refresh(ctx context.Context) (agentsvc.Inventory, error) +} + +// AgentsController owns the /agents routes. +type AgentsController struct { + Catalog AgentCatalog +} + +// Register mounts the agent inventory routes on the supplied router. +func (c *AgentsController) Register(r chi.Router) { + r.Get("/agents", c.list) + r.Post("/agents/refresh", c.refresh) +} + +func (c *AgentsController) list(w http.ResponseWriter, r *http.Request) { + if c.Catalog == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/agents") + return + } + inventory, err := c.Catalog.List(r.Context()) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, inventory) +} + +func (c *AgentsController) refresh(w http.ResponseWriter, r *http.Request) { + if c.Catalog == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/agents/refresh") + return + } + inventory, err := c.Catalog.Refresh(r.Context()) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, inventory) +} diff --git a/backend/internal/httpd/controllers/agents_test.go b/backend/internal/httpd/controllers/agents_test.go new file mode 100644 index 000000000..76e0d0545 --- /dev/null +++ b/backend/internal/httpd/controllers/agents_test.go @@ -0,0 +1,94 @@ +package controllers_test + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" +) + +type fakeAgentCatalog struct { + inventory agentsvc.Inventory + refreshed agentsvc.Inventory + err error + listCalls int + refreshCalls int +} + +func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) { + f.listCalls++ + return f.inventory, f.err +} + +func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error) { + f.refreshCalls++ + if f.refreshed.Supported != nil { + return f.refreshed, f.err + } + return f.inventory, f.err +} + +func TestListAgents(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + catalog := &fakeAgentCatalog{inventory: agentsvc.Inventory{ + Supported: []agentsvc.Info{{ID: "claude-code", Label: "Claude Code"}, {ID: "codex", Label: "Codex"}}, + Installed: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + Authorized: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + }} + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + Agents: catalog, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodGet, "/api/v1/agents", "") + if status != http.StatusOK { + t.Fatalf("GET /agents = %d, body=%s", status, body) + } + for _, want := range []string{`"supported"`, `"installed"`, `"authorized"`, `"id":"codex"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("body missing %s: %s", want, body) + } + } + if strings.Contains(string(body), `"counts"`) { + t.Fatalf("body includes removed counts field: %s", body) + } + if catalog.listCalls != 1 || catalog.refreshCalls != 0 { + t.Fatalf("calls: list=%d refresh=%d, want list=1 refresh=0", catalog.listCalls, catalog.refreshCalls) + } +} + +func TestRefreshAgents(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + catalog := &fakeAgentCatalog{ + inventory: agentsvc.Inventory{Supported: []agentsvc.Info{{ID: "codex", Label: "Codex"}}}, + refreshed: agentsvc.Inventory{ + Supported: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + Installed: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + Authorized: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + }, + } + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + Agents: catalog, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/agents/refresh", "") + if status != http.StatusOK { + t.Fatalf("POST /agents/refresh = %d, body=%s", status, body) + } + for _, want := range []string{`"supported"`, `"installed"`, `"authorized"`, `"id":"codex"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("body missing %s: %s", want, body) + } + } + if catalog.listCalls != 0 || catalog.refreshCalls != 1 { + t.Fatalf("calls: list=%d refresh=%d, want list=0 refresh=1", catalog.listCalls, catalog.refreshCalls) + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index ab859ef7a..0f830b244 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -7,6 +7,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) @@ -435,6 +436,15 @@ type OrchestratorResponse struct { ProjectName string `json:"projectName,omitempty"` } +// ListAgentsResponse is the body of GET /api/v1/agents. +type ListAgentsResponse = agentsvc.Inventory + +// RefreshAgentsResponse is the body of POST /api/v1/agents/refresh. +type RefreshAgentsResponse = agentsvc.Inventory + +// AgentInfo is one supported or installed agent entry. +type AgentInfo = agentsvc.Info + // ListNotificationsQuery is the query string accepted by GET /api/v1/notifications. type ListNotificationsQuery struct { Status string `query:"status,omitempty" enum:"unread" description:"Notification status filter. V1 supports only unread."` diff --git a/backend/internal/httpd/server_test.go b/backend/internal/httpd/server_test.go index 299d218f6..a315c78a0 100644 --- a/backend/internal/httpd/server_test.go +++ b/backend/internal/httpd/server_test.go @@ -96,6 +96,7 @@ func TestServerLifecycle(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) + defer cancel() runErr := make(chan error, 1) go func() { runErr <- srv.Run(ctx) }() @@ -109,6 +110,7 @@ func TestServerLifecycle(t *testing.T) { } if info == nil { t.Fatal("run-file not written while server running") + return } if info.Port == 0 { t.Error("run-file recorded port 0; want the actual bound port") diff --git a/backend/internal/observe/scm/observer_test.go b/backend/internal/observe/scm/observer_test.go index f07b1fef5..d75d217a7 100644 --- a/backend/internal/observe/scm/observer_test.go +++ b/backend/internal/observe/scm/observer_test.go @@ -681,6 +681,7 @@ func TestPoll_DiscoveredPRPersistedAsBaselineBeforeRefresh(t *testing.T) { } if baseline == nil { t.Fatalf("discovered PR #1 not persisted as a baseline row; writes=%#v", store.writes) + return } if baseline.Merged || baseline.Closed { t.Fatalf("baseline row must be open, got merged=%v closed=%v", baseline.Merged, baseline.Closed) diff --git a/backend/internal/ports/agent.go b/backend/internal/ports/agent.go index 9b5d6a9e2..0b8c39dfb 100644 --- a/backend/internal/ports/agent.go +++ b/backend/internal/ports/agent.go @@ -14,6 +14,22 @@ import ( // for a live session. var ErrAgentBinaryNotFound = errors.New("agent: binary not found on PATH") +// AgentAuthStatus describes the result of a short local auth probe for an +// installed agent. It is advisory only: credentials, quota, selected model +// availability, or CLI state can still fail at session spawn/model-call time. +type AgentAuthStatus string + +const ( + // AgentAuthStatusAuthorized means the local auth probe recently passed. + // It does not guarantee that a later spawn or model call will succeed. + AgentAuthStatusAuthorized AgentAuthStatus = "authorized" + // AgentAuthStatusUnauthorized means the agent is installed but its local + // auth probe reported missing or invalid authentication. + AgentAuthStatusUnauthorized AgentAuthStatus = "unauthorized" + // AgentAuthStatusUnknown means the daemon could not determine auth status. + AgentAuthStatusUnknown AgentAuthStatus = "unknown" +) + // Agent is the contract every CLI coding agent adapter (claude-code, codex, …) // must satisfy. It supplies the argv and process configuration the Session // Manager needs to launch, restore, and read back a native agent session. @@ -42,6 +58,18 @@ type Agent interface { SessionInfo(ctx context.Context, session SessionRef) (info SessionInfo, ok bool, err error) } +// AgentAuthChecker is the optional capability for adapters whose native CLI has +// a cheap local authentication status probe. +type AgentAuthChecker interface { + AuthStatus(ctx context.Context) (AgentAuthStatus, error) +} + +// AgentBinaryResolver is the optional capability adapters expose when their +// binary can be checked without constructing a real session launch command. +type AgentBinaryResolver interface { + ResolveBinary(ctx context.Context) (path string, err error) +} + // AgentResolver maps a session's harness onto the Agent adapter that drives it, // so the Session Manager can spawn (and restore) a different agent per session // without depending on the concrete adapter registry. ok=false means no adapter diff --git a/backend/internal/runfile/runfile_test.go b/backend/internal/runfile/runfile_test.go index 87b62b320..162421e40 100644 --- a/backend/internal/runfile/runfile_test.go +++ b/backend/internal/runfile/runfile_test.go @@ -20,6 +20,7 @@ func TestWriteReadRoundTrip(t *testing.T) { } if got == nil { t.Fatal("Read returned nil for an existing file") + return } if got.PID != want.PID || got.Port != want.Port || !got.StartedAt.Equal(want.StartedAt) { t.Errorf("round trip mismatch: got %+v, want %+v", *got, want) @@ -44,6 +45,7 @@ func TestWriteReadRoundTripOwner(t *testing.T) { } if got == nil { t.Fatal("Read returned nil for an existing file") + return } if got.Owner != "app" { t.Errorf("Owner round trip: got %q, want %q", got.Owner, "app") @@ -60,6 +62,7 @@ func TestWriteReadRoundTripOwner(t *testing.T) { } if got == nil { t.Fatal("Read returned nil for headless file") + return } if got.Owner != "" { t.Errorf("headless Owner round trip: got %q, want %q", got.Owner, "") @@ -164,6 +167,7 @@ func TestCheckStaleLivePID(t *testing.T) { } if live == nil { t.Fatal("CheckStale on live PID = nil, want the live Info") + return } if live.PID != os.Getpid() { t.Errorf("live.PID = %d, want %d", live.PID, os.Getpid()) diff --git a/backend/internal/service/agent/catalog_test.go b/backend/internal/service/agent/catalog_test.go new file mode 100644 index 000000000..2df062e3c --- /dev/null +++ b/backend/internal/service/agent/catalog_test.go @@ -0,0 +1,303 @@ +package agent + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type fakeAgent struct { + err error + delay time.Duration +} + +type fakeAuthAgent struct { + fakeAgent + status ports.AgentAuthStatus + authErr error + authDelay time.Duration +} + +type probeTrackingAgent struct { + fakeAgent + onProbe func() +} + +func (f fakeAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, nil +} + +func (f fakeAgent) GetLaunchCommand(ctx context.Context, _ ports.LaunchConfig) ([]string, error) { + if f.delay > 0 { + select { + case <-time.After(f.delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if f.err != nil { + return nil, f.err + } + return []string{"agent"}, nil +} + +func (f fakeAgent) ResolveBinary(ctx context.Context) (string, error) { + if f.delay > 0 { + select { + case <-time.After(f.delay): + case <-ctx.Done(): + return "", ctx.Err() + } + } + if f.err != nil { + return "", f.err + } + return "agent", nil +} + +func (f probeTrackingAgent) ResolveBinary(ctx context.Context) (string, error) { + if f.onProbe != nil { + f.onProbe() + } + return f.fakeAgent.ResolveBinary(ctx) +} + +func (f fakeAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + return ports.PromptDeliveryInCommand, nil +} + +func (f fakeAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { + return nil +} + +func (f fakeAgent) GetRestoreCommand(context.Context, ports.RestoreConfig) ([]string, bool, error) { + return nil, false, nil +} + +func (f fakeAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) { + return ports.SessionInfo{}, false, nil +} + +func (f fakeAuthAgent) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if f.authDelay > 0 { + select { + case <-time.After(f.authDelay): + case <-ctx.Done(): + return ports.AgentAuthStatusUnknown, ctx.Err() + } + } + return f.status, f.authErr +} + +func TestListReturnsInitialSupportedInventoryWithoutProbing(t *testing.T) { + probed := false + svc := NewWithAgents([]agentregistry.HarnessAgent{ + { + Harness: domain.AgentHarness("codex"), + Manifest: adapters.Manifest{ + ID: "codex", + Name: "Codex", + }, + Agent: probeTrackingAgent{onProbe: func() { probed = true }}, + }, + }) + + got, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if probed { + t.Fatal("List ran a live probe") + } + if len(got.Supported) != 1 || got.Supported[0].ID != "codex" { + t.Fatalf("supported = %#v, want codex", got.Supported) + } + if len(got.Installed) != 0 || len(got.Authorized) != 0 { + t.Fatalf("inventory = %#v, want only supported entries before refresh", got) + } + if got.Installed == nil { + t.Fatal("Installed = nil, want empty slice") + } + if got.Authorized == nil { + t.Fatal("Authorized = nil, want empty slice") + } +} + +func TestRefreshReportsInstalledAgentsAndIgnoresDetectorErrors(t *testing.T) { + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAgent("codex", "Codex", nil), + harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound), + harnessAgent("broken", "Broken", errors.New("unexpected detector failure")), + }) + + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if len(got.Supported) != 3 { + t.Fatalf("supported = %#v, want 3 agents", got.Supported) + } + if len(got.Installed) != 1 || got.Installed[0].ID != "codex" { + t.Fatalf("installed = %#v, want only codex", got.Installed) + } +} + +func TestRefreshReportsAuthorizedInstalledAgents(t *testing.T) { + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAuthAgent("codex", "Codex", ports.AgentAuthStatusAuthorized, nil), + harnessAuthAgent("claude-code", "Claude Code", ports.AgentAuthStatusUnauthorized, nil), + harnessAgent("opencode", "OpenCode", nil), + harnessAuthAgent("broken-auth", "Broken Auth", ports.AgentAuthStatusAuthorized, errors.New("probe failed")), + }) + + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if len(got.Supported) != 4 || len(got.Installed) != 4 { + t.Fatalf("inventory = %#v, want supported=4 installed=4", got) + } + if len(got.Authorized) != 1 || got.Authorized[0].ID != "codex" { + t.Fatalf("authorized = %#v, want only codex", got.Authorized) + } + + byID := map[string]Info{} + for _, info := range got.Installed { + byID[info.ID] = info + } + if byID["codex"].AuthStatus != ports.AgentAuthStatusAuthorized { + t.Fatalf("codex authStatus = %q", byID["codex"].AuthStatus) + } + if byID["claude-code"].AuthStatus != ports.AgentAuthStatusUnauthorized { + t.Fatalf("claude-code authStatus = %q", byID["claude-code"].AuthStatus) + } + if byID["opencode"].AuthStatus != ports.AgentAuthStatusUnknown { + t.Fatalf("opencode authStatus = %q", byID["opencode"].AuthStatus) + } + if byID["broken-auth"].AuthStatus != ports.AgentAuthStatusUnknown { + t.Fatalf("broken-auth authStatus = %q", byID["broken-auth"].AuthStatus) + } +} + +func TestRefreshDoesNotWaitForSlowAgentProbe(t *testing.T) { + previous := agentInstallProbeTimeout + agentInstallProbeTimeout = 20 * time.Millisecond + t.Cleanup(func() { agentInstallProbeTimeout = previous }) + + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAgent("codex", "Codex", nil), + { + Harness: domain.AgentHarness("slow"), + Manifest: adapters.Manifest{ + ID: "slow", + Name: "Slow", + }, + Agent: fakeAgent{delay: time.Minute}, + }, + }) + + start := time.Now() + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if elapsed := time.Since(start); elapsed > 500*time.Millisecond { + t.Fatalf("List took %s, want bounded by slow probe timeout", elapsed) + } + if len(got.Supported) != 2 { + t.Fatalf("supported = %#v, want both agents", got.Supported) + } + if len(got.Installed) != 1 || got.Installed[0].ID != "codex" { + t.Fatalf("installed = %#v, want only codex", got.Installed) + } +} + +func TestRefreshUsesSeparateTimeoutForAuthProbe(t *testing.T) { + previousInstall := agentInstallProbeTimeout + previousAuth := agentAuthProbeTimeout + agentInstallProbeTimeout = 20 * time.Millisecond + agentAuthProbeTimeout = 200 * time.Millisecond + t.Cleanup(func() { + agentInstallProbeTimeout = previousInstall + agentAuthProbeTimeout = previousAuth + }) + + svc := NewWithAgents([]agentregistry.HarnessAgent{ + { + Harness: domain.AgentHarness("claude-code"), + Manifest: adapters.Manifest{ + ID: "claude-code", + Name: "Claude Code", + }, + Agent: fakeAuthAgent{ + fakeAgent: fakeAgent{}, + status: ports.AgentAuthStatusAuthorized, + authDelay: 75 * time.Millisecond, + }, + }, + }) + + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if len(got.Authorized) != 1 || got.Authorized[0].ID != "claude-code" { + t.Fatalf("authorized = %#v, want claude-code", got.Authorized) + } +} + +func TestRefreshIsRateLimited(t *testing.T) { + previous := agentRefreshMinInterval + agentRefreshMinInterval = time.Hour + t.Cleanup(func() { agentRefreshMinInterval = previous }) + + probes := 0 + svc := NewWithAgents([]agentregistry.HarnessAgent{ + { + Harness: domain.AgentHarness("codex"), + Manifest: adapters.Manifest{ + ID: "codex", + Name: "Codex", + }, + Agent: probeTrackingAgent{onProbe: func() { probes++ }}, + }, + }) + + if _, err := svc.Refresh(context.Background()); err != nil { + t.Fatalf("first Refresh: %v", err) + } + if _, err := svc.Refresh(context.Background()); err != nil { + t.Fatalf("second Refresh: %v", err) + } + if probes != 1 { + t.Fatalf("probes = %d, want 1", probes) + } +} + +func harnessAgent(id, label string, err error) agentregistry.HarnessAgent { + return agentregistry.HarnessAgent{ + Harness: domain.AgentHarness(id), + Manifest: adapters.Manifest{ + ID: id, + Name: label, + }, + Agent: fakeAgent{err: err}, + } +} + +func harnessAuthAgent(id, label string, status ports.AgentAuthStatus, err error) agentregistry.HarnessAgent { + return agentregistry.HarnessAgent{ + Harness: domain.AgentHarness(id), + Manifest: adapters.Manifest{ + ID: id, + Name: label, + }, + Agent: fakeAuthAgent{fakeAgent: fakeAgent{}, status: status, authErr: err}, + } +} diff --git a/backend/internal/service/agent/service.go b/backend/internal/service/agent/service.go new file mode 100644 index 000000000..2a2391294 --- /dev/null +++ b/backend/internal/service/agent/service.go @@ -0,0 +1,214 @@ +package agent + +import ( + "context" + "errors" + "sort" + "sync" + "time" + + agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var ( + agentInstallProbeTimeout = 2 * time.Second + agentAuthProbeTimeout = 10 * time.Second + agentRefreshMinInterval = 10 * time.Second +) + +type probeResult struct { + info Info + installed bool + authorized bool +} + +// Info is the user-facing identity for an agent adapter. +type Info struct { + ID string `json:"id"` + Label string `json:"label"` + AuthStatus ports.AgentAuthStatus `json:"authStatus,omitempty" enum:"authorized,unauthorized,unknown" description:"Advisory local auth probe result. authorized means a recent local probe passed; spawn remains the authoritative validation point."` +} + +// Inventory describes all daemon-supported agents and best-effort local probe +// results. Installed/authorized entries are advisory snapshots and can be stale; +// session spawn is the authoritative validation point for binary availability, +// runtime prerequisites, and model-call readiness. +type Inventory struct { + Supported []Info `json:"supported" description:"Agents supported by this daemon build."` + Installed []Info `json:"installed" description:"Agents whose binary resolved during the latest best-effort local catalog probe."` + Authorized []Info `json:"authorized" description:"Compatibility list of installed agents whose local auth probe recently returned authorized. Advisory and stale-prone; spawn may still fail."` +} + +// Service reports supported agent adapters and best-effort local readiness +// probes. Catalog readiness is advisory UI metadata, not a spawn precheck. +type Service struct { + agents []agentregistry.HarnessAgent + + mu sync.RWMutex + inventory Inventory + lastRefresh time.Time + refreshMu sync.Mutex +} + +// New returns an agent inventory service backed by the daemon's shipped +// adapter registry. +func New() *Service { + return NewWithAgents(agentregistry.Harnessed()) +} + +// NewWithAgents returns an inventory service over a caller-provided adapter +// slice. It is used by focused tests. +func NewWithAgents(agents []agentregistry.HarnessAgent) *Service { + return &Service{agents: agents, inventory: Inventory{ + Supported: supportedInfos(agents), + Installed: []Info{}, + Authorized: []Info{}, + }} +} + +// List returns the cached agent inventory without running probes. Installed and +// authorized entries come from the last explicit Refresh call and are advisory: +// they can be stale by the time a user starts a session, and session spawn +// performs the authoritative binary/runtime validation. +func (s *Service) List(ctx context.Context) (Inventory, error) { + if err := ctx.Err(); err != nil { + return Inventory{}, err + } + s.mu.RLock() + defer s.mu.RUnlock() + return cloneInventory(s.inventory), nil +} + +// Refresh runs the bounded local binary/auth probes, updates the cached +// inventory, and returns the new snapshot. Refreshes are serialized and +// rate-limited so repeated frontend reloads cannot stampede agent CLIs. +func (s *Service) Refresh(ctx context.Context) (Inventory, error) { + if err := ctx.Err(); err != nil { + return Inventory{}, err + } + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + + s.mu.RLock() + if !s.lastRefresh.IsZero() && time.Since(s.lastRefresh) < agentRefreshMinInterval { + cached := cloneInventory(s.inventory) + s.mu.RUnlock() + return cached, nil + } + s.mu.RUnlock() + + results := make(chan probeResult, len(s.agents)) + var wg sync.WaitGroup + for _, item := range s.agents { + if err := ctx.Err(); err != nil { + return Inventory{}, err + } + wg.Add(1) + go func(item agentregistry.HarnessAgent) { + defer wg.Done() + results <- probeAgent(ctx, item) + }(item) + } + wg.Wait() + close(results) + + supported := make([]Info, 0, len(s.agents)) + installed := make([]Info, 0, len(s.agents)) + authorized := make([]Info, 0, len(s.agents)) + for res := range results { + supported = append(supported, res.info) + if res.installed { + installed = append(installed, res.info) + } + if res.authorized { + authorized = append(authorized, res.info) + } + } + sortInfos(supported) + sortInfos(installed) + sortInfos(authorized) + next := Inventory{ + Supported: supported, + Installed: installed, + Authorized: authorized, + } + s.mu.Lock() + s.inventory = cloneInventory(next) + s.lastRefresh = time.Now() + s.mu.Unlock() + return next, nil +} + +func supportedInfos(agents []agentregistry.HarnessAgent) []Info { + supported := make([]Info, 0, len(agents)) + for _, item := range agents { + info := Info{ID: string(item.Harness), Label: item.Manifest.Name} + if info.Label == "" { + info.Label = info.ID + } + supported = append(supported, info) + } + sortInfos(supported) + return supported +} + +func cloneInventory(in Inventory) Inventory { + return Inventory{ + Supported: cloneInfos(in.Supported), + Installed: cloneInfos(in.Installed), + Authorized: cloneInfos(in.Authorized), + } +} + +func cloneInfos(in []Info) []Info { + out := make([]Info, len(in)) + copy(out, in) + return out +} + +func probeAgent(ctx context.Context, item agentregistry.HarnessAgent) probeResult { + info := Info{ID: string(item.Harness), Label: item.Manifest.Name} + if info.Label == "" { + info.Label = info.ID + } + probeCtx, cancel := context.WithTimeout(ctx, agentInstallProbeTimeout) + defer cancel() + resolver, ok := item.Agent.(ports.AgentBinaryResolver) + if !ok { + return probeResult{info: info} + } + if _, err := resolver.ResolveBinary(probeCtx); err != nil { + return probeResult{info: info} + } + authCtx, authCancel := context.WithTimeout(ctx, agentAuthProbeTimeout) + defer authCancel() + info.AuthStatus = authStatus(authCtx, item.Agent) + return probeResult{info: info, installed: true, authorized: info.AuthStatus == ports.AgentAuthStatusAuthorized} +} + +func authStatus(ctx context.Context, a ports.Agent) ports.AgentAuthStatus { + checker, ok := a.(ports.AgentAuthChecker) + if !ok { + return ports.AgentAuthStatusUnknown + } + status, err := checker.AuthStatus(ctx) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return ports.AgentAuthStatusUnknown + } + return ports.AgentAuthStatusUnknown + } + switch status { + case ports.AgentAuthStatusAuthorized, ports.AgentAuthStatusUnauthorized: + return status + default: + return ports.AgentAuthStatusUnknown + } +} + +func sortInfos(infos []Info) { + sort.Slice(infos, func(i, j int) bool { + return infos[i].ID < infos[j].ID + }) +} diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index af8593737..c8d2e3d65 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -45,10 +46,11 @@ type SessionTeardowner interface { // Service implements project registration and lookup use-cases for controllers. type Service struct { - store Store - sessions SessionTeardowner - clock func() time.Time - telemetry ports.EventSink + store Store + sessions SessionTeardowner + clock func() time.Time + telemetry ports.EventSink + defaultHarness domain.AgentHarness // addMu serialises the whole body of Add. Workspace registration performs // filesystem mutations (git init, .gitignore writes, commits) that are not // covered by the store's own writeMu, so path/id conflict checks plus the @@ -60,10 +62,13 @@ var _ Manager = (*Service)(nil) // Deps captures optional collaborators for project use-cases. type Deps struct { - Store Store - Sessions SessionTeardowner - Clock func() time.Time - Telemetry ports.EventSink + // DefaultHarness is the daemon's configured default agent (AO_AGENT). + // When empty, the service falls back to config.DefaultAgent. + DefaultHarness domain.AgentHarness + Store Store + Sessions SessionTeardowner + Clock func() time.Time + Telemetry ports.EventSink } // New returns a project service backed by the given durable store. @@ -73,7 +78,17 @@ func New(store Store) *Service { // NewWithDeps returns a project service with optional teardown dependencies. func NewWithDeps(d Deps) *Service { - s := &Service{store: d.Store, sessions: d.Sessions, clock: d.Clock, telemetry: d.Telemetry} + defaultHarness := d.DefaultHarness + if defaultHarness == "" { + defaultHarness = domain.AgentHarness(config.DefaultAgent) + } + s := &Service{ + store: d.Store, + sessions: d.Sessions, + clock: d.Clock, + telemetry: d.Telemetry, + defaultHarness: defaultHarness, + } if s.clock == nil { s.clock = time.Now } @@ -111,7 +126,7 @@ func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, erro if !ok || !row.ArchivedAt.IsZero() { return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") } - p := projectFromRow(row) + p := m.projectFromRow(row) if row.Kind.WithDefault() == domain.ProjectKindWorkspace { repos, err := m.store.ListWorkspaceRepos(ctx, row.ID) if err != nil { @@ -174,12 +189,12 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { }) } - var config domain.ProjectConfig + var projectConfig domain.ProjectConfig if in.Config != nil { if err := in.Config.Validate(); err != nil { return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil) } - config = *in.Config + projectConfig = *in.Config } registeredAt := time.Now() @@ -189,7 +204,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { DisplayName: name, RegisteredAt: registeredAt, Kind: domain.ProjectKindSingleRepo, - Config: config, + Config: projectConfig, } if in.AsWorkspace { repos, err := prepareWorkspaceProject(ctx, path, domain.ProjectID(row.ID), registeredAt) @@ -202,7 +217,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register workspace project") } m.emitProjectAdded(row, projectCountBefore == 0) - p := projectFromRow(row) + p := m.projectFromRow(row) p.WorkspaceRepos = workspaceReposFromRecords(repos) return p, nil } @@ -224,7 +239,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") } m.emitProjectAdded(row, projectCountBefore == 0) - return projectFromRow(row), nil + return m.projectFromRow(row), nil } func (m *Service) activeProjectCount(ctx context.Context) (int, error) { @@ -286,7 +301,7 @@ func (m *Service) SetConfig(ctx context.Context, id domain.ProjectID, in SetConf if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, apierr.Internal("PROJECT_CONFIG_UPDATE_FAILED", "Failed to update project config") } - return projectFromRow(row), nil + return m.projectFromRow(row), nil } // resolveGitOriginURL returns the project's `origin` remote URL via @@ -365,7 +380,7 @@ func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.P } } -func projectFromRow(row domain.ProjectRecord) Project { +func (m *Service) projectFromRow(row domain.ProjectRecord) Project { p := Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), @@ -373,6 +388,7 @@ func projectFromRow(row domain.ProjectRecord) Project { Path: row.Path, Repo: row.RepoOriginURL, DefaultBranch: row.Config.WithDefaults().DefaultBranch, + Agent: string(m.defaultHarness), } if !row.Config.IsZero() { cfg := row.Config diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 4901b83bd..f10dba67e 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -272,6 +272,9 @@ func TestManager_DefaultsWhenUnconfigured(t *testing.T) { if got.Project.DefaultBranch != domain.DefaultBranchName { t.Fatalf("default branch = %q, want %q", got.Project.DefaultBranch, domain.DefaultBranchName) } + if got.Project.Agent != "claude-code" { + t.Fatalf("default agent = %q, want claude-code", got.Project.Agent) + } if got.Project.Config != nil { t.Fatalf("unconfigured project should omit config, got %#v", got.Project.Config) } @@ -285,6 +288,32 @@ func TestManager_DefaultsWhenUnconfigured(t *testing.T) { } } +func TestManager_GetUsesConfiguredDefaultHarness(t *testing.T) { + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + m := project.NewWithDeps(project.Deps{Store: store, DefaultHarness: domain.HarnessCodex}) + repo := gitRepo(t) + + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil { + t.Fatalf("Add: %v", err) + } + + got, err := m.Get(ctx, "ao") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Project == nil { + t.Fatalf("Get returned no project: %#v", got) + } + if got.Project.Agent != "codex" { + t.Fatalf("default agent = %q, want codex", got.Project.Agent) + } +} + func TestManager_AddDetectsNonMainDefaultBranch(t *testing.T) { ctx := context.Background() m := newManager(t) diff --git a/backend/internal/service/session/claim_pr.go b/backend/internal/service/session/claim_pr.go index 54070e1dc..a0818102f 100644 --- a/backend/internal/service/session/claim_pr.go +++ b/backend/internal/service/session/claim_pr.go @@ -47,9 +47,11 @@ type ClaimPRResult struct { // ListPRs returns all PRs currently owned by a session, ordered for display. func (s *Service) ListPRs(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error) { - if _, ok, err := s.store.GetSession(ctx, id); err != nil { + _, ok, err := s.store.GetSession(ctx, id) + if err != nil { return nil, fmt.Errorf("get %s: %w", id, err) - } else if !ok { + } + if !ok { return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } return s.listPRFacts(ctx, id) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 4a808c24a..9fc3af565 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -97,7 +97,9 @@ type Store interface { // presence of any row is the marker; preserved_ref may be empty for clean // worktrees. ListSessionWorktrees(ctx context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) - // DeleteSessionWorktrees clears the "shutdown-saved" restore marker. + // DeleteSessionWorktrees consumes stale shutdown-restore markers. Explicit + // Kill and successful RestoreAll must remove these rows to prevent + // resurrecting sessions the user intentionally terminated. DeleteSessionWorktrees(ctx context.Context, id domain.SessionID) error } @@ -450,11 +452,8 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { if err := m.lcm.MarkTerminated(ctx, id); err != nil { return false, fmt.Errorf("kill %s: %w", id, err) } - - // Clear the restore marker so the next boot's RestoreAll cannot resurrect a - // killed session (#2319). Best-effort: teardown below still matters. if err := m.store.DeleteSessionWorktrees(ctx, id); err != nil { - m.logger.Warn("kill: delete restore marker failed", "sessionID", id, "error", err) + return false, fmt.Errorf("kill %s: delete restore marker: %w", id, err) } // Only tear down what exists. A session may have lost its handle after a @@ -849,12 +848,10 @@ func (m *Manager) RestoreAll(ctx context.Context) error { } else { m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err) } + continue } - - // One-shot: drop the consumed marker so it never outlives one restart - // (#2319). A still-live session re-acquires it at the next quit. if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil { - m.logger.Warn("restore-all: delete restore marker failed", "sessionID", rec.ID, "error", err) + m.logger.Error("restore-all: delete consumed worktree marker failed", "sessionID", rec.ID, "error", err) } } return nil diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 261881cd0..679f7a6e5 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -564,6 +564,25 @@ func TestKill_DirtyWorkspaceTerminatesAndPreserves(t *testing.T) { } } +func TestKill_DeletesStaleRestoreMarker(t *testing.T) { + m, st, _, _ := newManager() + st.sessions["mer-1"] = mkLive("mer-1") + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, WorktreePath: "/tmp/wt"}, + } + + freed, err := m.Kill(ctx, "mer-1") + if err != nil { + t.Fatalf("Kill: %v", err) + } + if !freed { + t.Fatal("Kill freed = false, want true") + } + if rows := st.worktrees["mer-1"]; len(rows) != 0 { + t.Fatalf("stale restore marker = %+v, want deleted", rows) + } +} + // TestKill_OtherWorkspaceErrorStillFails: only the typed dirty refusal is a // success-with-preserved-workspace; any other teardown failure keeps erroring. func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) { @@ -574,29 +593,6 @@ func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) { t.Fatalf("kill err = %v, want workspace error surfaced", err) } } - -// TestKill_DeletesRestoreMarker covers issue #2319 (a): a user kill is explicit -// terminal intent and must delete the session_worktrees "shutdown-saved" marker. -// A session that carried a marker (e.g. it survived a prior reopen cycle) and is -// then killed must not keep that marker, or the next boot's RestoreAll would -// resurrect it. -func TestKill_DeletesRestoreMarker(t *testing.T) { - m, st, _, _ := newManager() - st.sessions["mer-1"] = mkLive("mer-1") - // The session carries a leftover shutdown-saved marker from a prior cycle. - st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} - - if _, err := m.Kill(ctx, "mer-1"); err != nil { - t.Fatalf("kill err = %v", err) - } - rows, err := st.ListSessionWorktrees(ctx, "mer-1") - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("kill must delete the restore marker, got %d rows", len(rows)) - } -} func TestRestore_ReopensTerminal(t *testing.T) { m, st, rt, _ := newManager() seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}) @@ -1580,6 +1576,33 @@ func TestRestoreAll_RestoresBothWorkerAndOrchestrator(t *testing.T) { } } +func TestRestoreAll_ConsumesMarkersAfterSuccessfulRestore(t *testing.T) { + m, st, rt, _ := newLifecycleManager() + + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, WorktreePath: "/ws/mer-1"}, + } + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v", err) + } + if rt.created != 1 { + t.Fatalf("RestoreAll must relaunch session, runtime.Create called %d times", rt.created) + } + if rows := st.worktrees["mer-1"]; len(rows) != 0 { + t.Fatalf("consumed restore marker = %+v, want deleted", rows) + } +} + // TestRestoreAll_SkipsSessionsKilledBeforeShutdown verifies (c): a session // the user killed BEFORE shutdown has no session_worktrees row and must NOT // be resurrected. @@ -1611,81 +1634,6 @@ func TestRestoreAll_SkipsSessionsKilledBeforeShutdown(t *testing.T) { } } -// TestRestoreAll_DeletesMarkerAfterRelaunch covers issue #2319 (b): the -// shutdown-saved marker is one-shot. After RestoreAll relaunches a session, its -// session_worktrees marker is deleted, so a second RestoreAll (with no fresh -// marker) does NOT relaunch it again. -func TestRestoreAll_DeletesMarkerAfterRelaunch(t *testing.T) { - m, st, rt, _ := newLifecycleManager() - - st.sessions["mer-1"] = domain.SessionRecord{ - ID: "mer-1", - ProjectID: "mer", - Kind: domain.KindWorker, - Harness: domain.HarnessClaudeCode, - IsTerminated: true, - Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, - Activity: domain.Activity{State: domain.ActivityExited}, - } - st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} - - if err := m.RestoreAll(ctx); err != nil { - t.Fatalf("RestoreAll err = %v", err) - } - if rt.created != 1 { - t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created) - } - rows, err := st.ListSessionWorktrees(ctx, "mer-1") - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("RestoreAll must delete the one-shot marker, got %d rows", len(rows)) - } -} - -// TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot covers issue #2319 (c), -// the killed-session-resurrection scenario. A terminated session WITH a marker -// is relaunched exactly once; on a second RestoreAll (no new marker) it stays -// terminated and is not relaunched again. -func TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot(t *testing.T) { - m, st, rt, _ := newLifecycleManager() - - st.sessions["mer-1"] = domain.SessionRecord{ - ID: "mer-1", - ProjectID: "mer", - Kind: domain.KindWorker, - Harness: domain.HarnessClaudeCode, - IsTerminated: true, - Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, - Activity: domain.Activity{State: domain.ActivityExited}, - } - st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} - - // First boot: marker present, session relaunches once. - if err := m.RestoreAll(ctx); err != nil { - t.Fatalf("first RestoreAll err = %v", err) - } - if rt.created != 1 { - t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created) - } - - // Simulate the user killing the relaunched session before the next quit, so - // it has no fresh marker, then a second boot. - if _, err := m.Kill(ctx, "mer-1"); err != nil { - t.Fatalf("kill err = %v", err) - } - if err := m.RestoreAll(ctx); err != nil { - t.Fatalf("second RestoreAll err = %v", err) - } - if rt.created != 1 { - t.Fatalf("killed session must NOT be resurrected on second boot, runtime.Create total = %d, want 1", rt.created) - } - if !st.sessions["mer-1"].IsTerminated { - t.Error("killed session must remain terminated after second RestoreAll") - } -} - // TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a // non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace // restore but before relaunching. diff --git a/docs/README.md b/docs/README.md index 073951ec3..1ecef2359 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,6 @@ Start with [architecture.md](architecture.md) for the current backend model and | [architecture.md](architecture.md) | Current backend model, package layout, status derivation, persistence/CDC, and load-bearing rules. | | [backend-code-structure.md](backend-code-structure.md) | Package ownership rules for the Go backend: domain, services, ports, adapters, storage, HTTP, CLI, and daemon wiring. | | [cli/README.md](cli/README.md) | CLI commands and daemon control surface. | -| [agent/README.md](agent/README.md) | Agent adapter contract, hook methodology, and session-info derivation. | | [STATUS.md](STATUS.md) | What is shipped on `main` today and what is still in flight. | | [stack.md](stack.md) | Accepted library/runtime choices, pending stack decisions, and dependencies explicitly avoided for V1. | diff --git a/docs/agent/README.md b/docs/agent/README.md deleted file mode 100644 index eca64de39..000000000 --- a/docs/agent/README.md +++ /dev/null @@ -1,950 +0,0 @@ -# Agent Adapter Contract - -This document defines the contract between Agent Orchestrator and CLI-based coding agent adapters. Every agent must implement the contract defined in `backend/internal/ports/agent.go` to be usable by AO. - -## Table of Contents - -- [Overview](#overview) -- [Agent Contract](#agent-contract) -- [Hook System](#hook-system) -- [Session Metadata](#session-metadata) -- [Agent Lifecycle](#agent-lifecycle) -- [Implementation Guide](#implementation-guide) -- [Supported Agents](#supported-agents) -- [Testing](#testing) - ---- - -## Overview - -Agent adapters let AO run and observe different CLI coding agents without hardcoding agent-specific behavior into the spawn engine. The adapter contract provides: - -- **Agent discovery** — Find and validate the agent binary -- **Launch configuration** — Build the native agent command -- **Session restoration** — Resume existing sessions -- **Hook integration** — Install and manage agent hooks -- **Metadata extraction** — Surface session title, summary, and native session ID - -```mermaid -graph TB - AO[Agent Orchestrator] -->|uses| Adapter[Agent Adapter] - Adapter -->|implements| Contract[ports.Agent Contract] - - Contract --> Methods[Required Methods] - Methods --> GetConfig[GetConfigSpec] - Methods --> GetLaunch[GetLaunchCommand] - Methods --> GetStrategy[GetPromptDeliveryStrategy] - Methods --> GetHooks[GetAgentHooks] - Methods --> GetRestore[GetRestoreCommand] - Methods --> SessionInfo[SessionInfo] - Methods --> Uninstall[UninstallHooks] - - Adapter --> Agent[Native CLI Agent] - Adapter --> Hooks[Agent Hook Config] - -``` - ---- - -## Agent Contract - -### Port Interface - -The `ports.Agent` interface (defined in `backend/internal/ports/agent.go`) specifies the required adapter methods: - -```go -// Agent is the interface all coding agent adapters must implement. -type Agent interface { - // GetConfigSpec describes user-facing agent configuration. - GetConfigSpec() ConfigSpec - - // GetLaunchCommand builds the native agent command for spawning a new session. - GetLaunchCommand(ctx context.Context, cfg LaunchConfig) ([]string, error) - - // GetPromptDeliveryStrategy reports how the prompt is delivered. - GetPromptDeliveryStrategy() PromptDeliveryStrategy - - // GetAgentHooks installs or merges AO hooks into the agent's workspace-local config. - GetAgentHooks(ctx context.Context, cfg WorkspaceHookConfig) error - - // GetRestoreCommand builds a command to resume an existing session. - GetRestoreCommand(ctx context.Context, cfg RestoreConfig) ([]string, bool, error) - - // SessionInfo returns normalized session metadata from the session record. - SessionInfo(ctx context.Context, session SessionRef) (SessionInfo, bool, error) - - // UninstallHooks removes AO hooks from the workspace. - UninstallHooks(ctx context.Context, workspacePath string) error -} -``` - -### Method Details - -#### GetConfigSpec() - -Returns a specification of user-facing configuration options: - -```go -type ConfigSpec struct { - // Permissions is the permission mode the agent supports. - Permissions PermissionMode - - // Model is the optional model identifier (e.g., "claude-3-5-sonnet-20241022"). - Model string - - // SupportsSystemPrompt indicates the agent accepts a system prompt. - SupportsSystemPrompt bool - - // SupportsRestore indicates the agent supports session restoration. - SupportsRestore bool -} -``` - -#### GetLaunchCommand() - -Builds the native agent launch command: - -```mermaid -flowchart LR - Input[LaunchConfig] --> Validate[Validate Config] - Validate --> Resolve[Resolve Binary] - Resolve --> Build[Build Command] - Build --> Apply[Apply Permissions] - Apply --> Model[Add Model Flag] - Model --> System[Add System Prompt] - System --> Prompt[Add Prompt] - Prompt --> Output[Launch Command] - -``` - -**Input (`LaunchConfig`):** - -- `SessionID` — AO session identifier -- `ProjectID` — AO project identifier -- `Prompt` — User prompt to send -- `Config` — Agent-specific configuration -- `Permissions` — Permission mode override -- `WorkspacePath` — Path to session worktree - -**Output:** Executable command line as argv array - -#### GetPromptDeliveryStrategy() - -Reports how the prompt is delivered to the agent: - -```go -type PromptDeliveryStrategy string - -const ( - // StrategyArgv means the prompt is passed as a command-line argument. - StrategyArgv PromptDeliveryStrategy = "argv" - - // StrategyStdin means the prompt is written to stdin after launch. - StrategyStdin PromptDeliveryStrategy = "stdin" -) -``` - -#### GetAgentHooks() - -Installs AO hooks into the agent's workspace-local configuration: - -```mermaid -flowchart TD - Input[WorkspaceHookConfig] --> Read[Read Existing Config] - Read --> Merge[Merge AO Hooks] - Merge --> Dedup[Deduplicate Entries] - Dedupe --> Preserve[Preserve User Hooks] - Preserve --> Write[Write Updated Config] - Write --> Gitignore[Update .gitignore] - -``` - -**Requirements:** - -- Preserve all user hooks -- Deduplicate AO hook entries -- Make AO hooks machine-portable (use `ao hooks ...` command) -- Ensure all written files are covered by `.gitignore` - -#### GetRestoreCommand() - -Builds a command to resume an existing session: - -**Input (`RestoreConfig`):** - -- `Session` — Complete session record with metadata -- `Permissions` — Permission mode to apply -- `SystemPrompt` — System prompt to re-apply - -**Output:** - -- `cmd` — Restore command argv -- `ok` — True if restore is supported -- `err` — Error if restore fails - -#### SessionInfo() - -Returns normalized session metadata from the stored session record: - -```go -type SessionInfo struct { - // AgentSessionID is the native agent's session identifier. - AgentSessionID string - - // Title is the display title derived from the first user prompt. - Title string - - // Summary is the display summary derived from the final assistant message. - Summary string - - // Metadata contains adapter-specific extra fields. - Metadata map[string]any -} -``` - -**Important:** `SessionInfo` must read from the `session.Metadata` map (populated by hooks), not from scanning agent transcript/cache files. - -#### UninstallHooks() - -Removes AO hooks from the workspace while preserving user hooks. - ---- - -## Hook System - -### Hook Overview - -AO uses agent hooks to receive activity signals and session metadata from running agents: - -```mermaid -sequenceDiagram - participant AO as AO Daemon - participant Adapter as Agent Adapter - participant Agent as Native Agent - participant Hook as Hook Command - - Note over AO: Spawning session - AO->>Adapter: GetAgentHooks(config) - Adapter->>Hook: Write hook config - Hook-->>Adapter: Config written - - AO->>Agent: Launch agent - Agent->>Agent: Running in worktree - - Note over Agent: Agent triggers event - Agent->>Hook: Execute hook callback - Hook->>Hook: Read stdin payload - Hook->>AO: POST /api/v1/sessions/{id}/activity - AO->>AO: Update activity_state - AO->>Hook: 200 OK - Hook-->>Agent: Exit 0 - - Agent->>Agent: Continue work -``` - -### Hook Events - -AO supports hooks for these agent events: - -| Event | Purpose | Activity State | -| ------------------ | --------------------- | ------------------ | -| `SessionStart` | Session initialized | - | -| `UserPromptSubmit` | User submitted prompt | `active` | -| `AssistantMessage` | Assistant response | - | -| `ToolUse` | Agent used a tool | `active` | -| `Stop` | Session stopped | `idle` or `exited` | - -### Hook Contract - -All hook callbacks follow this contract: - -```bash -# Hook command format -ao hooks - -# Input (stdin) -# JSON payload from agent (agent-specific format) - -# Output (stdout) -# None (discarded) - -# Exit code -# 0 = success (always, even on delivery failure) -``` - -### Hook Behavior - -```mermaid -flowchart TD - Trigger[Agent Event Triggered] --> Execute[Execute Hook Command] - Execute --> ReadEnv[Read AO_SESSION_ID
from environment] - ReadEnv --> CheckSession{Is AO Session?} - CheckSession -->|No| Exit[Exit 0 - ignore] - CheckSession -->|Yes| ReadStdin[Read JSON Payload
from stdin] - ReadStdin --> Derive[Derive Activity State
from event] - Derive --> Post[POST to Daemon
/api/v1/sessions/{id}/activity] - Post --> Success{Success?} - Success -->|Yes| Update[Daemon updates
activity_state] - Success -->|No| Log[Log to hooks.log
under AO_DATA_DIR] - Update --> Exit - Log --> Exit - -``` - -**Critical rules:** - -1. **Always exit 0** — Hook failure must never break the user's agent -2. **Log failures** — Append to `hooks.log` under `AO_DATA_DIR` -3. **Best-effort delivery** — The daemon may be temporarily unavailable -4. **Idempotent** — Duplicate hook deliveries are safe - -### Hook Installation Patterns - -Different agents use different hook mechanisms: - -#### Claude Code / Compatible Agents - -```json -// .claude/hooks.json -{ - "SessionStart": ["ao hooks claude-code SessionStart"], - "UserPromptSubmit": ["ao hooks claude-code UserPromptSubmit"], - "Stop": ["ao hooks claude-code Stop"] -} -``` - -#### Factory Droid - -```json -// .factory/hooks.json -{ - "hooks": [ - { - "event": "agent:beforeThinking", - "command": "ao hooks droid beforeThinking" - }, - { - "event": "agent:afterThinking", - "command": "ao hooks droid afterThinking" - } - ] -} -``` - -#### Codex (Session Flags) - -```bash -# Codex passes hooks as session flags -codex -c 'hooks.SessionStart=["ao hooks codex SessionStart"]' \ - -c 'hooks.Stop=["ao hooks codex Stop"]' \ - --dangerously-bypass-hook-trust -``` - ---- - -## Session Metadata - -### Metadata Keys - -Hook callbacks persist normalized keys in the session metadata JSON blob: - -```go -const ( - // MetadataKeyAgentSessionID is the native agent's session identifier. - MetadataKeyAgentSessionID = "agentSessionId" - - // MetadataKeyTitle is the display title from the first user prompt. - MetadataKeyTitle = "title" - - // MetadataKeySummary is the display summary from the final assistant message. - MetadataKeySummary = "summary" -) -``` - -### Metadata Flow - -```mermaid -sequenceDiagram - participant Hook as Hook Callback - participant Daemon as AO Daemon - participant Store as SQLite Store - participant UI as Dashboard - - Note over Hook: User submits first prompt - Hook->>Daemon: POST /activity
{state: "active", title: "..."} - Daemon->>Store: Update session metadata
metadata.title = "..." - Store->>Store: CDC: session.updated - Store->>UI: SSE: session.updated - UI->>UI: Update session title - - Note over Hook: Agent finishes work - Hook->>Daemon: POST /activity
{state: "idle", summary: "..."} - Daemon->>Store: Update session metadata
metadata.summary = "..." - Store->>Store: CDC: session.updated - Store->>UI: SSE: session.updated - UI->>UI: Update session summary -``` - -### Metadata Persistence - -```mermaid -flowchart LR - Hook[Hook Callback] --> POST[POST Activity] - POST --> LCM[Lifecycle Manager] - LCM --> Update[Update Session Record] - Update --> Metadata[Set Metadata Fields] - Metadata --> CDC[CDC Broadcast] - CDC --> UI[Dashboard Updates] - -``` - ---- - -## Agent Lifecycle - -### Spawn Flow - -```mermaid -sequenceDiagram - participant User as User - participant UI as Dashboard - participant API as HTTP API - participant Mgr as Session Manager - participant Adapter as Agent Adapter - participant Runtime as Runtime - participant Agent as Agent Process - - User->>UI: Click "Spawn Session" - UI->>API: POST /sessions - API->>Mgr: Spawn(config) - - Note over Mgr: 1. Create session row - Mgr->>Mgr: Insert session with empty metadata - - Note over Mgr: 2. Prepare workspace - Mgr->>Adapter: GetAgentHooks(config) - Adapter->>Adapter: Write hook config - Adapter-->>Mgr: Hooks installed - - Note over Mgr: 3. Build launch command - Mgr->>Adapter: GetLaunchCommand(config) - Adapter-->>Mgr: Launch command argv - - Note over Mgr: 4. Execute in runtime - Mgr->>Runtime: Execute(agent command) - Runtime->>Agent: Spawn process - - Note over Agent: Agent starts - Agent->>Agent: Execute SessionStart hook - Agent->>API: POST /activity (agentSessionId) - API->>API: Update metadata - - Agent-->>Runtime: Running - Runtime-->>Mgr: Session active - Mgr-->>API: Session response - API-->>UI: Session created -``` - -### Restore Flow - -```mermaid -sequenceDiagram - participant User as User - participant UI as Dashboard - participant API as HTTP API - participant Mgr as Session Manager - participant Adapter as Agent Adapter - participant Runtime as Runtime - participant Agent as Agent Process - - User->>UI: Click "Restore Session" - UI->>API: POST /sessions/{id}/restore - API->>Mgr: Restore(session) - - Note over Mgr: Check adapter supports restore - Mgr->>Adapter: GetRestoreCommand(config) - - alt Restore supported - Adapter-->>Mgr: Restore command - Mgr->>Runtime: Execute(restore command) - Runtime->>Agent: Spawn process - Agent-->>Mgr: Running - Mgr-->>API: Session restored - else Restore not supported - Adapter-->>Mgr: (cmd, false, nil) - Mgr->>Mgr: Fresh spawn instead - Mgr-->>API: Session created (new) - end - - API-->>UI: Session response -``` - -### Activity Flow - -```mermaid -stateDiagram-v2 - [*] --> Spawning: Spawn() - Spawning --> Idle: Agent starts - Idle --> Active: User prompt - Active --> Active: Agent working - Active --> Waiting: Needs input - Waiting --> Active: User responds - Active --> Idle: Agent completes - Idle --> Exited: Agent exits - Active --> Exited: Agent crashes - Exited --> [*] - - note right of Active - Hook: UserPromptSubmit - Hook: ToolUse - activity_state = "active" - end note - - note right of Waiting - Hook: WaitingForInput - activity_state = "waiting_input" - end note - - note right of Idle - Hook: Stop - activity_state = "idle" - end note - - note right of Exited - Hook: Stop - activity_state = "exited" - is_terminated = true - end note -``` - ---- - -## Implementation Guide - -### Adapter Template - -```go -package myagent - -import ( - "context" - "fmt" -) - -// Plugin is the MyAgent adapter. -type Plugin struct { - // Adapter state (e.g., resolved binary path) -} - -// New returns a ready-to-register MyAgent adapter. -func New() *Plugin { - return &Plugin{} -} - -// GetConfigSpec describes the agent configuration. -func (p *Plugin) GetConfigSpec() ports.ConfigSpec { - return ports.ConfigSpec{ - Permissions: ports.PermissionReadWrite, // or ReadOnly, WriteOnly - SupportsSystemPrompt: true, - SupportsRestore: true, - } -} - -// GetLaunchCommand builds the native agent command. -func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ([]string, error) { - // 1. Validate config - if err := cfg.Config.Validate(); err != nil { - return nil, fmt.Errorf("myagent: %w", err) - } - - // 2. Resolve binary - binary, err := p.resolveBinary(ctx) - if err != nil { - return nil, err - } - - // 3. Build command - cmd := []string{binary} - - // Add flags (model, permissions, etc.) - if cfg.Config.Model != "" { - cmd = append(cmd, "--model", cfg.Config.Model) - } - - // Add system prompt if supported - if cfg.SystemPrompt != "" { - cmd = append(cmd, "--system-prompt", cfg.SystemPrompt) - } - - // Add prompt - if cfg.Prompt != "" { - cmd = append(cmd, "--prompt", cfg.Prompt) - } - - return cmd, nil -} - -// GetPromptDeliveryStrategy reports how the prompt is delivered. -func (p *Plugin) GetPromptDeliveryStrategy() ports.PromptDeliveryStrategy { - return ports.StrategyArgv // or StrategyStdin -} - -// GetAgentHooks installs AO hooks. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - // 1. Read existing config - config, err := p.readConfig(cfg.WorkspacePath) - if err != nil { - return err - } - - // 2. Add AO hooks (preserving user hooks) - config = p.addHooks(config, cfg.SessionID) - - // 3. Write updated config - if err := p.writeConfig(cfg.WorkspacePath, config); err != nil { - return err - } - - // 4. Ensure .gitignore coverage - if err := p.ensureGitignore(cfg.WorkspacePath); err != nil { - return err - } - - return nil -} - -// GetRestoreCommand builds a restore command. -func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) ([]string, bool, error) { - // 1. Extract native session ID from metadata - sessionID := cfg.Session.Metadata[ports.MetadataKeyAgentSessionID] - if sessionID == "" { - return nil, false, nil // Restore not supported - } - - // 2. Build restore command - binary, err := p.resolveBinary(ctx) - if err != nil { - return nil, false, err - } - - cmd := []string{ - binary, - "--resume", sessionID, - // Re-apply permissions and system prompt - } - - return cmd, true, nil -} - -// SessionInfo returns normalized metadata. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], - } - - // Return hasData=true if any field is populated - hasData := info.AgentSessionID != "" || info.Title != "" || info.Summary != "" - - return info, hasData, nil -} - -// UninstallHooks removes AO hooks. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - // Remove AO hook entries while preserving user hooks - config, err := p.readConfig(workspacePath) - if err != nil { - return err - } - - config = p.removeHooks(config) - - return p.writeConfig(workspacePath, config) -} -``` - -### Hook Implementation - -```go -// Add hooks to the agent's config -func (p *Plugin) addHooks(config map[string]any, sessionID string) map[string]any { - // Define AO hook commands - aoHooks := map[string]string{ - "SessionStart": "ao hooks myagent SessionStart", - "UserPromptSubmit": "ao hooks myagent UserPromptSubmit", - "Stop": "ao hooks myagent Stop", - } - - // Merge with existing config, preserving user hooks - for event, command := range aoHooks { - if config[event] == nil { - config[event] = []string{command} - } else { - // Deduplicate: check if AO hook already exists - hooks := config[event].([]string) - found := false - for _, h := range hooks { - if strings.HasPrefix(h, "ao hooks myagent") { - found = true - break - } - } - if !found { - config[event] = append(hooks, command) - } - } - } - - return config -} -``` - -### Gitignore Enforcement - -```go -import "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" - -func (p *Plugin) ensureGitignore(workspacePath string) error { - // Every file the adapter writes must be gitignored - filesToIgnore := []string{ - ".myagent/config.json", - ".myagent/hooks.json", - } - - return hookutil.EnsureWorkspaceGitignore(workspacePath, ".myagent/") -} -``` - ---- - -## Supported Agents - -### Current Adapters - -AO currently supports 23+ agent adapters: - -```mermaid -mindmap - root((Agent Adapters)) - Anthropic - Claude Code - OpenAI - Codex - Cursor - Cursor - OpenCode - OpenCode - Aider - Aider - Amp - Amp Code - Goose - Goose - GitHub - Copilot - xAI - Grok - Alibaba - Qwen Code - Moonshot - Kimi Code - Reverb - Crush - Cline - Cline - Factory - Droid - Devin - Augment - Auggie - Continue - Continue - Kiro - Kiro - Kilo - Kilo Code - Agy - Agy - Roo - Roo Code - Windsurf - Windsurf -``` - -### Adapter Locations - -``` -backend/internal/adapters/agent/ -├── agy/ -├── aider/ -├── amp/ -├── augie/ -├── claudecode/ -├── clone/ -├── codex/ -├── continue/ -├── cursor/ -├── devin/ -├── droid/ -├── grok/ -├── goose/ -├── kilo/ -├── kimi/ -├── kiro/ -├── opencode/ -├── qwen/ -├── roo/ -├── crush/ -├── windsurf/ -└── ... -``` - -### Adapter Capabilities - -| Agent | Permissions | System Prompt | Restore | Hooks | -| ----------- | ----------- | ------------- | ------- | ----------------- | -| Claude Code | ✓ | ✓ | ✓ | ✓ | -| Codex | ✓ | ✓ | ✓ | ✓ (session flags) | -| Cursor | ✓ | ✓ | ✗ | ✗ | -| OpenCode | ✓ | ✓ | ✗ | ✗ | -| Aider | ✓ | ✓ | ✓ | ✓ | -| Amp | ✓ | ✓ | ✓ | ✓ | -| Grok | ✓ | ✓ | ✓ | ✓ (Claude compat) | -| ... | ... | ... | ... | ... | - ---- - -## Testing - -### Unit Tests - -```go -func TestGetLaunchCommand(t *testing.T) { - p := New() - cfg := ports.LaunchConfig{ - SessionID: "test-session", - Prompt: "Write a function", - Config: domain.AgentConfig{ - Model: "gpt-4", - }, - } - - cmd, err := p.GetLaunchCommand(context.Background(), cfg) - assert.NoError(t, err) - assert.Contains(t, cmd, "myagent") - assert.Contains(t, cmd, "--model", "gpt-4") - assert.Contains(t, cmd, "Write a function") -} -``` - -### Integration Tests - -```go -func TestAgentE2E(t *testing.T) { - // Skip if agent not installed - if !agentInstalled(t) { - t.Skip("myagent not installed") - } - - // Create test project - ctx := context.Background() - project := createTestProject(t) - - // Spawn session - session := spawnSession(t, ctx, project.ID, "myagent", "Hello") - - // Wait for activity - waitForActivity(t, ctx, session.ID, "active") - - // Verify metadata - info := getSessionInfo(t, ctx, session.ID) - assert.NotEmpty(t, info.AgentSessionID) - assert.NotEmpty(t, info.Title) - - // Kill session - killSession(t, ctx, session.ID) -} -``` - -### Conformance Tests - -```go -// TestGetAgentHooksFootprintIsGitignored verifies all adapter-written -// files are covered by .gitignore. -func TestGetAgentHooksFootprintIsGitignored(t *testing.T) { - registry := agentRegistry.New() - for _, adapter := range registry.List() { - t.Run(adapter, func(t *testing.T) { - tmpDir := t.TempDir() - cfg := ports.WorkspaceHookConfig{ - WorkspacePath: tmpDir, - SessionID: "test-session", - } - - agent := registry.Get(adapter) - err := agent.GetAgentHooks(context.Background(), cfg) - assert.NoError(t, err) - - // Verify all written files are gitignored - verifyGitignore(t, tmpDir) - }) - } -} -``` - -### Hook Tests - -```go -func TestHookDelivery(t *testing.T) { - // Mock daemon endpoint - server := mockActivityServer(t) - defer server.Close() - - // Simulate hook callback - payload := map[string]any{ - "state": "active", - "title": "Test session", - } - - cmd := exec.Command("ao", "hooks", "myagent", "UserPromptSubmit", "test-session") - cmd.Env = append(cmd.Env, - fmt.Sprintf("AO_SESSION_ID=%s", "test-session"), - fmt.Sprintf("AO_DAEMON_URL=%s", server.URL), - ) - - stdin, _ := cmd.StdinPipe() - json.NewEncoder(stdin).Encode(payload) - stdin.Close() - - err := cmd.Run() - assert.NoError(t, err) - - // Verify daemon received the activity - assert.ActivityReceived(t, server, "active") -} -``` - ---- - -## Summary - -**Key points:** - -1. **Port contract** — All agents implement `ports.Agent` -2. **Hooks are critical** — Activity signals and metadata flow through hooks -3. **Always gitignore** — Every adapter-written file must be covered -4. **Metadata from hooks** — `SessionInfo` reads from metadata, not files -5. **Preserve user hooks** — Never delete user configuration -6. **Exit 0 always** — Hook failure must never break the agent - -**Implementation checklist:** - -- [ ] Implement `ports.Agent` interface -- [ ] Write hook installation/removal -- [ ] Ensure all files are gitignored -- [ ] Implement `SessionInfo` from metadata -- [ ] Add unit tests -- [ ] Add integration tests -- [ ] Add conformance tests -- [ ] Register in daemon wiring diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 8bef18801..fe960f8e0 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -4,6 +4,40 @@ */ export interface paths { + "/api/v1/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Return cached supported and locally installed agent adapters */ + get: operations["listAgents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/agents/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Refresh the cached local agent adapter catalog */ + post: operations["refreshAgents"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/events": { parameters: { query?: never; @@ -512,6 +546,15 @@ export interface components { model?: string; permissions?: string; }; + AgentInfo: { + /** + * @description Advisory local auth probe result. authorized means a recent local probe passed; spawn remains the authoritative validation point. + * @enum {string} + */ + authStatus?: "authorized" | "unauthorized" | "unknown"; + id: string; + label: string; + }; ClaimPRRequest: { allowTakeover?: null | boolean; pr: string; @@ -587,6 +630,14 @@ export interface components { ok: boolean; sessionId: string; }; + ListAgentsResponse: { + /** @description Compatibility list of installed agents whose local auth probe recently returned authorized. Advisory and stale-prone; spawn may still fail. */ + authorized: components["schemas"]["AgentInfo"][]; + /** @description Agents whose binary resolved during the latest best-effort local catalog probe. */ + installed: components["schemas"]["AgentInfo"][]; + /** @description Agents supported by this daemon build. */ + supported: components["schemas"]["AgentInfo"][]; + }; ListNotificationsResponse: { notifications: components["schemas"]["NotificationResponse"][]; }; @@ -937,6 +988,82 @@ export interface components { } export type $defs = Record; export interface operations { + listAgents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListAgentsResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + refreshAgents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListAgentsResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; streamEvents: { parameters: { query?: { diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx index 6101d11e0..4c0f08697 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx @@ -1,17 +1,36 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { agentsQueryKey } from "../hooks/useAgentsQuery"; import { CreateProjectAgentSheet } from "./CreateProjectAgentSheet"; function renderSheet(onSubmit = vi.fn().mockResolvedValue(undefined)) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(agentsQueryKey, { + supported: [ + { id: "claude-code", label: "claude-code" }, + { id: "codex", label: "codex" }, + ], + installed: [ + { id: "claude-code", label: "claude-code", authStatus: "authorized" }, + { id: "codex", label: "codex", authStatus: "authorized" }, + ], + authorized: [ + { id: "claude-code", label: "claude-code", authStatus: "authorized" }, + { id: "codex", label: "codex", authStatus: "authorized" }, + ], + }); render( - undefined} - onSubmit={onSubmit} - open={true} - path="/repo/new-project" - />, + + undefined} + onSubmit={onSubmit} + open={true} + path="/repo/new-project" + /> + , ); return onSubmit; } diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index 5f9e1a67a..745ac9a72 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -1,15 +1,19 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as Dialog from "@radix-ui/react-dialog"; -import { X } from "lucide-react"; +import { TriangleAlert, X } from "lucide-react"; import { memo, useEffect, useState } from "react"; -import { AGENT_OPTIONS, DEFAULT_PROJECT_AGENT } from "../lib/agent-options"; +import type { components } from "../../api/schema"; +import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; +import { AGENT_OPTIONS } from "../lib/agent-options"; import { buildIntake, type IntakeForm, IntakeFields, intakeNeedsRule } from "./IntakeFields"; import { Button } from "./ui/button"; import { Label } from "./ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; -import type { components } from "../../api/schema"; type TrackerIntakeConfig = components["schemas"]["TrackerIntakeConfig"]; +type AgentInfo = components["schemas"]["AgentInfo"]; + export type CreateProjectAgentSelection = { workerAgent: string; orchestratorAgent: string; @@ -35,16 +39,36 @@ export function CreateProjectAgentSheet({ open, path, }: CreateProjectAgentSheetProps) { - const [workerAgent, setWorkerAgent] = useState(DEFAULT_PROJECT_AGENT); - const [orchestratorAgent, setOrchestratorAgent] = useState(DEFAULT_PROJECT_AGENT); + const queryClient = useQueryClient(); + const agentsQuery = useQuery({ + ...agentsQueryOptions, + enabled: open, + }); + const refreshAgentsMutation = useMutation({ + mutationFn: refreshAgents, + onSuccess: (next) => queryClient.setQueryData(agentsQueryKey, next), + }); + const agents = agentsQuery.data; + const installedAgents = agents?.installed ?? []; + const agentOptions = agents?.authorized ?? []; + const supportedAgents = agents?.supported ?? []; + const isLoadingAgents = agents === undefined && agentsQuery.isFetching; + const agentsError = agentsQuery.isError + ? agentsQuery.error instanceof Error + ? agentsQuery.error.message + : "Could not load agent catalog." + : null; + const [workerAgent, setWorkerAgent] = useState(""); + const [orchestratorAgent, setOrchestratorAgent] = useState(""); const [intake, setIntake] = useState(EMPTY_INTAKE); const intakeIncomplete = intakeNeedsRule(intake); - const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isCreating; + const canSubmit = + workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isCreating && !isLoadingAgents; useEffect(() => { if (!open) { - setWorkerAgent(DEFAULT_PROJECT_AGENT); - setOrchestratorAgent(DEFAULT_PROJECT_AGENT); + setWorkerAgent(""); + setOrchestratorAgent(""); setIntake(EMPTY_INTAKE); } }, [open, path]); @@ -86,6 +110,10 @@ export function CreateProjectAgentSheet({ label="Worker agent" placeholder="Select worker agent" value={workerAgent} + authorized={agentOptions} + installed={installedAgents} + supported={supportedAgents} + disabled={isLoadingAgents} onChange={setWorkerAgent} />
+ {isLoadingAgents &&

Loading agents...

} + +
+ Agent availability is cached. + +
+ + {agentsError && ( +
+ {agentsError} + +
+ )} + + {refreshAgentsMutation.isError && ( +
+ {refreshAgentsMutation.error instanceof Error + ? refreshAgentsMutation.error.message + : "Could not refresh agent catalog."} +
+ )} +
setIntake((f) => ({ ...f, ...patch }))} compact />
@@ -123,33 +190,78 @@ export function CreateProjectAgentSheet({ } export const RequiredAgentField = memo(function RequiredAgentField({ + authorized, + disabled = false, id, invalid = false, + installed, label, onChange, placeholder, + supported, value, }: { + authorized?: AgentInfo[]; + disabled?: boolean; id: string; invalid?: boolean; + installed?: AgentInfo[]; label: string; onChange: (value: string) => void; placeholder: string; + supported?: AgentInfo[]; value: string; }) { + const fallbackAgents: AgentInfo[] = AGENT_OPTIONS.map((agent) => ({ id: agent, label: agent })); + const supportedAgents = supported ?? fallbackAgents; + const installedAgents = installed ?? supportedAgents; + const authorizedAgents = authorized ?? supportedAgents; + const authorizedIds = new Set(authorizedAgents.map((agent) => agent.id)); + const installedById = new Map(installedAgents.map((agent) => [agent.id, agent])); + const options = supportedAgents + .map((agent) => { + const installedAgent = installedById.get(agent.id); + const authStatus = installedAgent?.authStatus; + const isAuthorized = authorizedIds.has(agent.id) || authStatus === "authorized"; + const isAuthUnknown = Boolean(installedAgent) && !isAuthorized && authStatus !== "unauthorized"; + const isSelectable = isAuthorized || isAuthUnknown; + const rank = isAuthorized ? 0 : isAuthUnknown ? 1 : installedAgent ? 2 : 3; + return { + ...agent, + disabled: !isSelectable, + rank, + reason: !installedAgent ? "Needs install" : isAuthUnknown ? "Auth unknown" : !isAuthorized ? "Needs auth" : "", + warning: isAuthUnknown, + }; + }) + .sort((a, b) => a.rank - b.rank || a.label.localeCompare(b.label) || a.id.localeCompare(b.id)); + return (
- - - {AGENT_OPTIONS.map((agent) => ( - - {agent} + + {options.map((agent) => ( + + + {agent.label} + {agent.reason && ( + + {agent.warning && + )} + ))} diff --git a/frontend/src/renderer/components/NewTaskDialog.test.tsx b/frontend/src/renderer/components/NewTaskDialog.test.tsx index d7e480f5a..9ec8474bc 100644 --- a/frontend/src/renderer/components/NewTaskDialog.test.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.test.tsx @@ -16,7 +16,9 @@ vi.mock("../lib/api-client", () => ({ }, apiErrorMessage: (error: unknown, fallback = "Request failed") => { if (typeof error === "object" && error !== null && "message" in error) { - return String((error as { message: unknown }).message); + const body = error as { code?: unknown; message: unknown }; + const message = String(body.message); + return typeof body.code === "string" && body.code !== "" ? `${message} (${body.code})` : message; } return fallback; }, @@ -37,10 +39,37 @@ function spawnBody() { return (postMock.mock.calls[0][1] as { body: Record }).body; } +async function waitForAgentCatalog() { + await waitFor(() => expect(screen.getAllByText("Claude Code").length).toBeGreaterThan(0)); +} + beforeEach(() => { - getMock.mockReset().mockResolvedValue({ - data: { status: "ok", project: { id: "proj-1", config: { worker: { agent: "claude-code" } } } }, - error: undefined, + getMock.mockReset().mockImplementation(async (path: string) => { + if (path === "/api/v1/agents") { + return { + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "cursor", label: "Cursor" }, + { id: "kiro", label: "Kiro" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "cursor", label: "Cursor", authStatus: "authorized" }, + { id: "kiro", label: "Kiro", authStatus: "unknown" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "cursor", label: "Cursor", authStatus: "authorized" }, + ], + }, + error: undefined, + }; + } + return { + data: { status: "ok", project: { id: "proj-1", config: { worker: { agent: "claude-code" } } } }, + error: undefined, + }; }); postMock.mockReset().mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined }); }); @@ -52,7 +81,7 @@ describe("NewTaskDialog", () => { const { onCreated, onOpenChange } = renderDialog(); const user = userEvent.setup(); - await screen.findByText("claude-code"); + await waitForAgentCatalog(); await user.type(screen.getByLabelText("Title"), "Fix fallback renderer"); await user.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails."); @@ -71,18 +100,18 @@ describe("NewTaskDialog", () => { }); expect(onCreated).toHaveBeenCalledWith("task-1"); expect(onOpenChange).toHaveBeenCalledWith(false); - }); + }, 10_000); - it("sends the chosen harness when the user overrides the default, including agents beyond the legacy four", async () => { + it("sends the chosen harness when the user overrides the default", async () => { renderDialog(); const user = userEvent.setup(); - await screen.findByText("claude-code"); + await waitForAgentCatalog(); await user.type(screen.getByLabelText("Title"), "T"); await user.type(screen.getByLabelText("Brief"), "B"); await user.click(screen.getByRole("combobox", { name: "Agent" })); - await user.click(await screen.findByRole("option", { name: "cursor" })); + await user.click(await screen.findByRole("option", { name: "Cursor" })); await user.click(screen.getByRole("button", { name: "Start task" })); @@ -90,6 +119,25 @@ describe("NewTaskDialog", () => { expect(spawnBody().harness).toBe("cursor"); }); + it("allows selecting an installed agent with unknown auth", async () => { + renderDialog(); + const user = userEvent.setup(); + await waitForAgentCatalog(); + + await user.click(screen.getByRole("combobox", { name: "Agent" })); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual(["Claude Code", "Cursor", "KiroAuth unknown"]); + expect(options[2]).not.toHaveAttribute("aria-disabled", "true"); + await user.click(options[2]); + + await user.type(screen.getByLabelText("Title"), "T"); + await user.type(screen.getByLabelText("Brief"), "B"); + await user.click(screen.getByRole("button", { name: "Start task" })); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(spawnBody().harness).toBe("kiro"); + }); + it("requires both title and brief", async () => { renderDialog(); const user = userEvent.setup(); @@ -99,4 +147,33 @@ describe("NewTaskDialog", () => { expect(await screen.findByText("Title and brief are required.")).toBeInTheDocument(); expect(postMock).not.toHaveBeenCalled(); }); + + it.each([ + { + code: "AGENT_BINARY_NOT_FOUND", + message: "agent binary not found on PATH", + }, + { + code: "RUNTIME_PREREQUISITE_MISSING", + message: "tmux required on macOS/Linux but not in PATH", + }, + { + code: "INTERNAL", + message: "runtime launch failed", + }, + ])("displays daemon spawn errors for $code", async ({ code, message }) => { + postMock.mockResolvedValueOnce({ + data: undefined, + error: { code, message }, + }); + renderDialog(); + const user = userEvent.setup(); + await waitForAgentCatalog(); + + await user.type(screen.getByLabelText("Title"), "Fix fallback renderer"); + await user.type(screen.getByLabelText("Brief"), "Restore fallback renderer."); + await user.click(screen.getByRole("button", { name: "Start task" })); + + expect(await screen.findByText(`${message} (${code})`)).toBeInTheDocument(); + }); }); diff --git a/frontend/src/renderer/components/NewTaskDialog.tsx b/frontend/src/renderer/components/NewTaskDialog.tsx index cef37d5b8..80a9bcddc 100644 --- a/frontend/src/renderer/components/NewTaskDialog.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.tsx @@ -1,5 +1,5 @@ import * as Dialog from "@radix-ui/react-dialog"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, X } from "lucide-react"; import { type FormEvent, useEffect, useId, useState } from "react"; import { Button } from "./ui/button"; @@ -8,6 +8,7 @@ import { RequiredAgentField } from "./CreateProjectAgentSheet"; import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import type { AgentProvider } from "../types/workspace"; +import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; type Project = components["schemas"]["Project"]; @@ -19,6 +20,7 @@ type NewTaskDialogProps = { }; export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewTaskDialogProps) { + const queryClient = useQueryClient(); const titleId = useId(); const promptId = useId(); const branchId = useId(); @@ -43,7 +45,16 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT return data.project as Project; }, }); + const agentsQuery = useQuery({ + ...agentsQueryOptions, + enabled: open, + }); + const refreshAgentsMutation = useMutation({ + mutationFn: refreshAgents, + onSuccess: (next) => queryClient.setQueryData(agentsQueryKey, next), + }); const defaultWorkerAgent = projectQuery.data?.config?.worker?.agent ?? ""; + const agentCatalog = agentsQuery.data; useEffect(() => { if (!open) { @@ -93,6 +104,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT onCreated(data.session.id); onOpenChange(false); } catch (err) { + void queryClient.invalidateQueries({ queryKey: agentsQueryKey }); setError(err instanceof Error ? err.message : "Unable to start task"); } finally { setIsSubmitting(false); @@ -150,16 +162,30 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
- { - setAgent(value); - setAgentTouched(true); - }} - /> +
+ { + setAgent(value); + setAgentTouched(true); + }} + /> + +
)} + {refreshAgentsMutation.isError && ( +
+ {refreshAgentsMutation.error instanceof Error + ? refreshAgentsMutation.error.message + : "Could not refresh agent catalog."} +
+ )} +
+
+ {refreshAgentsMutation.isError && ( +

+ {refreshAgentsMutation.error instanceof Error + ? refreshAgentsMutation.error.message + : "Could not refresh agent catalog."} +

+ )} + {missingRequiredAgent && ( +

Worker and orchestrator agents are required.

+ )} - claude-code (default) + Project default {REVIEWER_OPTIONS.map((reviewer) => ( {reviewer} diff --git a/frontend/src/renderer/components/SessionsBoard.test.tsx b/frontend/src/renderer/components/SessionsBoard.test.tsx new file mode 100644 index 000000000..2b55fcb47 --- /dev/null +++ b/frontend/src/renderer/components/SessionsBoard.test.tsx @@ -0,0 +1,40 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { navigateMock, workspaceQueryMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), + workspaceQueryMock: vi.fn(), +})); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock("../hooks/useWorkspaceQuery", () => ({ + useWorkspaceQuery: workspaceQueryMock, +})); + +import { SessionsBoard } from "./SessionsBoard"; + +function renderBoard() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +beforeEach(() => { + navigateMock.mockReset(); + workspaceQueryMock.mockReset().mockReturnValue({ data: [], isError: false }); +}); + +describe("SessionsBoard", () => { + it("does not show an agent setup warning on the board", () => { + renderBoard(); + + expect(screen.queryByText(/reload agents/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 261ca406c..6e85b89fa 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -1,6 +1,8 @@ -import { useState, type KeyboardEvent } from "react"; +import { type KeyboardEvent, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; + +import { DashboardSubhead } from "./DashboardSubhead"; import { Plus } from "lucide-react"; import { type AttentionZone, @@ -11,7 +13,6 @@ import { } from "../types/workspace"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; -import { DashboardSubhead } from "./DashboardSubhead"; import { OrchestratorIcon } from "./icons"; import { NewTaskDialog } from "./NewTaskDialog"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; @@ -271,13 +272,10 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: ( const showBranch = branch !== "" && !sameLabel(branch, session.title) && !sameLabel(branch, session.id); const prSummaries = sessionPRDisplaySummaries(session, useSessionScmSummary(session.id).data); const handleKeyDown = (event: KeyboardEvent) => { - if (event.target !== event.currentTarget) { - return; - } - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onOpen(); - } + if (event.currentTarget !== event.target) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onOpen(); }; return (
({ +const { getMock, navigateMock, mockParams, renameSessionMock } = vi.hoisted(() => ({ + getMock: vi.fn(), navigateMock: vi.fn(), mockParams: { projectId: undefined as string | undefined }, renameSessionMock: vi.fn().mockResolvedValue(undefined), @@ -19,12 +21,23 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { return { ...actual, useNavigate: () => navigateMock, - useParams: () => mockParams, + useParams: () => ({}), useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => unknown }) => select({ location: { pathname: "/" } }), }; }); +vi.mock("../lib/api-client", () => ({ + apiClient: { GET: getMock }, + apiErrorMessage: (error: unknown) => { + if (error instanceof Error) return error.message; + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { + return error.message; + } + return "Request failed"; + }, +})); + const workspace: WorkspaceSummary = { id: "proj-1", name: "Project One", @@ -51,15 +64,33 @@ type RemoveProjectHandler = (projectId: string) => Promise; function renderSidebar({ onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler, onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler, + seedAgents = true, workspaces = [workspace], }: { onCreateProject?: CreateProjectHandler; onRemoveProject?: RemoveProjectHandler; + seedAgents?: boolean; workspaces?: WorkspaceSummary[]; } = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); + if (seedAgents) { + queryClient.setQueryData(agentsQueryKey, { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }); + } render( @@ -81,6 +112,24 @@ async function chooseOption(trigger: HTMLElement, optionName: string) { } beforeEach(() => { + getMock.mockReset(); + getMock.mockResolvedValue({ + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }, + error: undefined, + }); navigateMock.mockReset(); renameSessionMock.mockReset().mockResolvedValue(undefined); mockParams.projectId = undefined; @@ -145,8 +194,8 @@ describe("Sidebar", () => { expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); const dialog = screen.getByRole("dialog", { name: "Project agents" }); expect(dialog).toHaveClass("left-1/2", "top-1/2", "-translate-x-1/2", "-translate-y-1/2"); - await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "codex"); - await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "claude-code"); + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Codex"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); await user.click(screen.getByRole("button", { name: "Create and start" })); await waitFor(() => @@ -158,26 +207,102 @@ describe("Sidebar", () => { ); }); - it("opens global settings from the footer menu when no project is selected", async () => { + it("shows needs-auth agents as unavailable while keeping authorized agents selectable", async () => { const user = userEvent.setup(); - renderSidebar(); + const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler; + window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project"); + getMock.mockResolvedValueOnce({ + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "cursor", label: "Cursor" }, + { id: "aider", label: "Aider" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "cursor", label: "Cursor", authStatus: "unauthorized" }, + ], + authorized: [{ id: "claude-code", label: "Claude Code", authStatus: "authorized" }], + }, + error: undefined, + }); + renderSidebar({ onCreateProject, seedAgents: false }); - await user.click(screen.getAllByLabelText("Settings")[0]); - await user.click(await screen.findByRole("menuitem", { name: "Global settings" })); + await user.click(screen.getByLabelText("New project")); + expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); - expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" }); + await user.click(screen.getByRole("combobox", { name: "Worker agent" })); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "Claude Code", + "CursorNeeds auth", + "AiderNeeds install", + ]); + expect(options[1]).toHaveAttribute("aria-disabled", "true"); + expect(options[2]).toHaveAttribute("aria-disabled", "true"); + await user.keyboard("{Escape}"); + + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Claude Code"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); + await user.click(screen.getByRole("button", { name: "Create and start" })); + + await waitFor(() => + expect(onCreateProject).toHaveBeenCalledWith(expect.objectContaining({ workerAgent: "claude-code" })), + ); }); - it("shows both project and global settings in the footer menu when a project is selected", async () => { - mockParams.projectId = "proj-1"; + it("updates project agent options when the catalog loads after the dialog opens", async () => { const user = userEvent.setup(); - renderSidebar(); + const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler; + window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project"); + let resolveAgents!: (value: { + data: { + supported: { id: string; label: string }[]; + installed: { id: string; label: string }[]; + authorized: { id: string; label: string; authStatus: "authorized" }[]; + }; + error: undefined; + }) => void; + getMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveAgents = resolve; + }), + ); + renderSidebar({ onCreateProject, seedAgents: false }); - await user.click(screen.getAllByLabelText("Settings")[0]); - expect(await screen.findByRole("menuitem", { name: "Project settings" })).toBeInTheDocument(); - await user.click(await screen.findByRole("menuitem", { name: "Global settings" })); + await user.click(screen.getByLabelText("New project")); + expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create and start" })).toBeDisabled(); - expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" }); + resolveAgents({ + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }, + error: undefined, + }); + + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Codex"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); + await user.click(screen.getByRole("button", { name: "Create and start" })); + + await waitFor(() => + expect(onCreateProject).toHaveBeenCalledWith({ + path: "/repo/new-project", + workerAgent: "codex", + orchestratorAgent: "claude-code", + }), + ); }); it("renames a session inline and persists via the daemon", async () => { diff --git a/frontend/src/renderer/components/ui/select.tsx b/frontend/src/renderer/components/ui/select.tsx index e8fd1f0be..c274cc642 100644 --- a/frontend/src/renderer/components/ui/select.tsx +++ b/frontend/src/renderer/components/ui/select.tsx @@ -65,11 +65,7 @@ function SelectContent({ > {children} diff --git a/frontend/src/renderer/hooks/useAgentsQuery.ts b/frontend/src/renderer/hooks/useAgentsQuery.ts new file mode 100644 index 000000000..6654e16b6 --- /dev/null +++ b/frontend/src/renderer/hooks/useAgentsQuery.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import type { components } from "../../api/schema"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; + +export type AgentCatalog = components["schemas"]["ListAgentsResponse"]; + +export const agentsQueryKey = ["agents"] as const; + +async function fetchAgents(): Promise { + const { data, error } = await apiClient.GET("/api/v1/agents"); + if (error) throw new Error(apiErrorMessage(error)); + return data as AgentCatalog; +} + +export async function refreshAgents(): Promise { + const { data, error } = await apiClient.POST("/api/v1/agents/refresh"); + if (error) throw new Error(apiErrorMessage(error)); + return data as AgentCatalog; +} + +export const agentsQueryOptions = { + queryKey: agentsQueryKey, + queryFn: fetchAgents, + retry: 1, + staleTime: 5 * 60 * 1000, +}; + +export function useAgentsQuery() { + return useQuery(agentsQueryOptions); +} diff --git a/frontend/src/renderer/lib/agent-options.ts b/frontend/src/renderer/lib/agent-options.ts index 39ade7eae..d26c9a4c0 100644 --- a/frontend/src/renderer/lib/agent-options.ts +++ b/frontend/src/renderer/lib/agent-options.ts @@ -23,7 +23,3 @@ export const AGENT_OPTIONS = [ "pi", "autohand", ] as const; - -// The agent new projects use by default, and the fallback for worker/orchestrator -// role fields that have no explicit configuration. Users can change it per project. -export const DEFAULT_PROJECT_AGENT: (typeof AGENT_OPTIONS)[number] = "claude-code"; diff --git a/frontend/src/renderer/lib/api-client.test.ts b/frontend/src/renderer/lib/api-client.test.ts index f72096dd8..df7898253 100644 --- a/frontend/src/renderer/lib/api-client.test.ts +++ b/frontend/src/renderer/lib/api-client.test.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { apiClient, getApiBaseUrl, hasTrustedApiBaseUrl, setApiBaseUrl, subscribeApiBaseUrl } from "./api-client"; +import { + apiClient, + apiErrorMessage, + getApiBaseUrl, + hasTrustedApiBaseUrl, + setApiBaseUrl, + subscribeApiBaseUrl, +} from "./api-client"; describe("apiClient runtime base URL", () => { afterEach(() => { @@ -154,3 +161,20 @@ describe("subscribeApiBaseUrl", () => { expect(listener).not.toHaveBeenCalled(); }); }); + +describe("apiErrorMessage", () => { + it("preserves daemon error codes next to human messages", () => { + expect(apiErrorMessage({ code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" })).toBe( + "agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)", + ); + }); + + it("does not duplicate a code that is already present in the message", () => { + expect( + apiErrorMessage({ + code: "RUNTIME_PREREQUISITE_MISSING", + message: "tmux required (RUNTIME_PREREQUISITE_MISSING)", + }), + ).toBe("tmux required (RUNTIME_PREREQUISITE_MISSING)"); + }); +}); diff --git a/frontend/src/renderer/lib/api-client.ts b/frontend/src/renderer/lib/api-client.ts index 9afdf193d..8bc2551aa 100644 --- a/frontend/src/renderer/lib/api-client.ts +++ b/frontend/src/renderer/lib/api-client.ts @@ -93,8 +93,11 @@ export function apiErrorMessage(error: unknown, fallback = "Request failed"): st if (error instanceof Error) return error.message; if (typeof error === "string" && error !== "") return error; if (typeof error === "object" && error !== null) { - const body = error as { message?: unknown; error?: unknown }; - if (typeof body.message === "string" && body.message !== "") return body.message; + const body = error as { code?: unknown; message?: unknown; error?: unknown }; + const code = typeof body.code === "string" && body.code !== "" ? body.code : ""; + if (typeof body.message === "string" && body.message !== "") { + return code && !body.message.includes(code) ? `${body.message} (${code})` : body.message; + } if (typeof body.error === "string" && body.error !== "") return body.error; } return fallback; diff --git a/frontend/src/renderer/lib/spawn-orchestrator.test.ts b/frontend/src/renderer/lib/spawn-orchestrator.test.ts index 34475155d..a8748fd20 100644 --- a/frontend/src/renderer/lib/spawn-orchestrator.test.ts +++ b/frontend/src/renderer/lib/spawn-orchestrator.test.ts @@ -4,6 +4,14 @@ import { apiClient } from "./api-client"; vi.mock("./api-client", () => ({ apiClient: { POST: vi.fn() }, + apiErrorMessage: (error: unknown, fallback = "Request failed") => { + if (typeof error === "object" && error !== null && "message" in error) { + const body = error as { code?: unknown; message: unknown }; + const message = String(body.message); + return typeof body.code === "string" && body.code !== "" ? `${message} (${body.code})` : message; + } + return fallback; + }, })); describe("spawnOrchestrator", () => { @@ -33,4 +41,14 @@ describe("spawnOrchestrator", () => { body: { projectId: "proj", clean: false }, }); }); + + it("surfaces daemon spawn error messages and codes", async () => { + (apiClient.POST as ReturnType).mockResolvedValue({ + data: undefined, + error: { code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" }, + response: { status: 400 }, + }); + + await expect(spawnOrchestrator("proj")).rejects.toThrow("agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)"); + }); }); diff --git a/frontend/src/renderer/lib/spawn-orchestrator.ts b/frontend/src/renderer/lib/spawn-orchestrator.ts index 1a0c2c81c..13d2f5533 100644 --- a/frontend/src/renderer/lib/spawn-orchestrator.ts +++ b/frontend/src/renderer/lib/spawn-orchestrator.ts @@ -1,4 +1,4 @@ -import { apiClient } from "./api-client"; +import { apiClient, apiErrorMessage } from "./api-client"; /** Spawn the project's orchestrator session via the daemon API. When clean is * true the daemon first tears down any active orchestrator for the project, then @@ -9,10 +9,9 @@ export async function spawnOrchestrator(projectId: string, clean = false): Promi }); if (error || !data?.orchestrator?.id) { - const message = - error && typeof error === "object" && "message" in error && typeof error.message === "string" - ? error.message - : `Failed to spawn orchestrator (${response.status})`; + const message = error + ? apiErrorMessage(error, `Failed to spawn orchestrator (${response.status})`) + : `Failed to spawn orchestrator (${response.status})`; throw new Error(message); } diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index 6dcbb2c42..a6f300d71 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -1,10 +1,11 @@ import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; -import { type CSSProperties, useCallback, useEffect } from "react"; +import { type CSSProperties, useCallback, useEffect, useRef } from "react"; import { ShellTopbar } from "../components/ShellTopbar"; import { Sidebar } from "../components/Sidebar"; import { SidebarProvider } from "../components/ui/sidebar"; import { TitlebarNav } from "../components/TitlebarNav"; +import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; import { useDaemonStatus } from "../hooks/useDaemonStatus"; import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery"; import { apiClient, apiErrorMessage } from "../lib/api-client"; @@ -45,6 +46,7 @@ function ShellLayout() { const workspaceQuery = useWorkspaceQuery(); const workspaces = workspaceQuery.data ?? []; const daemonStatus = useDaemonStatus(queryClient); + const agentCatalogPortRef = useRef(undefined); const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore(); const updateWorkspaces = useCallback( @@ -67,6 +69,10 @@ function ShellLayout() { surface: "project_board", }); void captureRendererEvent("ao.renderer.project_add_requested"); + const status = await refreshDaemonStatus(); + if (status.state !== "ready" || !status.port) { + throw new Error(status.message || "AO daemon is not ready."); + } const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path, @@ -145,6 +151,16 @@ function ShellLayout() { document.documentElement.style.colorScheme = theme; }, [theme]); + useEffect(() => { + if (daemonStatus.state !== "ready" || !daemonStatus.port) return; + if (agentCatalogPortRef.current === daemonStatus.port) return; + + agentCatalogPortRef.current = daemonStatus.port; + void queryClient.invalidateQueries({ queryKey: agentsQueryKey }); + void queryClient.fetchQuery({ ...agentsQueryOptions, queryFn: refreshAgents }); + void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + }, [daemonStatus.port, daemonStatus.state, queryClient]); + // Follow OS appearance only until the user picks a theme explicitly. useEffect(() => { if (readStoredTheme()) return; diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts index 0125bd75a..992db2685 100644 --- a/frontend/src/renderer/types/workspace.test.ts +++ b/frontend/src/renderer/types/workspace.test.ts @@ -124,6 +124,12 @@ describe("findProjectOrchestrator", () => { expect(findProjectOrchestrator([workspaceWith([dead, live, worker])], "skills")).toBe(live); }); + it("prefers the newest live orchestrator when multiple replacements overlap", () => { + const older = sessionWith({ id: "skills-4", kind: "orchestrator", status: "idle", provider: "claude-code" }); + const newer = sessionWith({ id: "skills-5", kind: "orchestrator", status: "working", provider: "codex" }); + expect(findProjectOrchestrator([workspaceWith([older, newer])], "skills")).toBe(newer); + }); + it("returns undefined when every orchestrator is terminated", () => { const dead = sessionWith({ id: "skills-4", kind: "orchestrator", status: "terminated" }); expect(findProjectOrchestrator([workspaceWith([dead])], "skills")).toBeUndefined(); diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index e8bb86f7e..d3e597fa4 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -241,7 +241,14 @@ export function findProjectOrchestrator( projectId: string, ): WorkspaceSession | undefined { const workspace = workspaces.find((w) => w.id === projectId); - return workspace?.sessions.find((session) => isOrchestratorSession(session) && sessionIsActive(session)); + if (!workspace) return undefined; + for (let i = workspace.sessions.length - 1; i >= 0; i -= 1) { + const session = workspace.sessions[i]; + if (isOrchestratorSession(session) && sessionIsActive(session)) { + return session; + } + } + return undefined; } export function workerSessions(sessions: WorkspaceSession[]): WorkspaceSession[] { From cc75a519ab2244534e47d8f766cf5778486976c9 Mon Sep 17 00:00:00 2001 From: NIKHIL ACHALE <96230495+nikhilachale@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:35:03 +0530 Subject: [PATCH 11/15] fix: replace orchestrators through safe canonical branch handoff (#2338) * fix: replace orchestrators through canonical handoff * fix(tests): correct logic in TestRetireForReplacementCapturesAndReleasesWorkspace * fix: enhance tests and add project config handling in service and workspace components * fix: update project summary to include orchestrator agent and adjust related tests * fix: block orchestrator replacement on runtime teardown failure * fix: enhance orchestrator handling in ProjectSettingsForm and SessionsBoard components * fix: rename parameter for clarity in projectConfigPtr function --- backend/internal/httpd/apispec/openapi.yaml | 2 + backend/internal/service/project/service.go | 24 ++-- .../internal/service/project/service_test.go | 26 ++++ backend/internal/service/project/types.go | 13 +- backend/internal/service/session/service.go | 112 +++++++++++++-- .../internal/service/session/service_test.go | 81 ++++++++++- backend/internal/session_manager/manager.go | 53 +++++++ .../internal/session_manager/manager_test.go | 130 +++++++++++++++++- frontend/src/api/schema.ts | 1 + .../OrchestratorReplacementDialog.tsx | 75 ++++++++++ .../components/ProjectSettingsForm.test.tsx | 115 +++++++++++++++- .../components/ProjectSettingsForm.tsx | 31 ++++- .../src/renderer/components/SessionsBoard.tsx | 52 +++++-- .../src/renderer/components/ShellTopbar.tsx | 11 +- frontend/src/renderer/components/Sidebar.tsx | 11 +- .../renderer/hooks/useWorkspaceQuery.test.tsx | 13 +- .../src/renderer/hooks/useWorkspaceQuery.ts | 1 + ...orchestrator-replacement-telemetry.test.ts | 26 ++++ .../lib/orchestrator-replacement-telemetry.ts | 10 ++ .../renderer/lib/restart-orchestrator.test.ts | 73 ++++++++++ .../src/renderer/lib/restart-orchestrator.ts | 52 +++++++ frontend/src/renderer/routes/_shell.tsx | 33 +++++ frontend/src/renderer/stores/ui-store.ts | 26 ++++ frontend/src/renderer/types/workspace.test.ts | 90 ++++++++++++ frontend/src/renderer/types/workspace.ts | 70 ++++++++-- 25 files changed, 1068 insertions(+), 63 deletions(-) create mode 100644 frontend/src/renderer/components/OrchestratorReplacementDialog.tsx create mode 100644 frontend/src/renderer/lib/orchestrator-replacement-telemetry.test.ts create mode 100644 frontend/src/renderer/lib/orchestrator-replacement-telemetry.ts create mode 100644 frontend/src/renderer/lib/restart-orchestrator.test.ts create mode 100644 frontend/src/renderer/lib/restart-orchestrator.ts diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index d3c628a46..2551c32ee 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -2069,6 +2069,8 @@ components: type: string name: type: string + orchestratorAgent: + type: string path: type: string resolveError: diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index c8d2e3d65..3f8da5cee 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -104,11 +104,12 @@ func (m *Service) List(ctx context.Context) ([]Summary, error) { out := make([]Summary, 0, len(projects)) for _, row := range projects { out = append(out, Summary{ - ID: domain.ProjectID(row.ID), - Name: displayName(row), - Path: row.Path, - Kind: row.Kind.WithDefault(), - SessionPrefix: resolveSessionPrefix(row), + ID: domain.ProjectID(row.ID), + Name: displayName(row), + Path: row.Path, + Kind: row.Kind.WithDefault(), + SessionPrefix: resolveSessionPrefix(row), + OrchestratorAgent: row.Config.Orchestrator.Harness, }) } return out, nil @@ -390,13 +391,18 @@ func (m *Service) projectFromRow(row domain.ProjectRecord) Project { DefaultBranch: row.Config.WithDefaults().DefaultBranch, Agent: string(m.defaultHarness), } - if !row.Config.IsZero() { - cfg := row.Config - p.Config = &cfg - } + p.Config = projectConfigPtr(row.Config) return p } +func projectConfigPtr(projectConfig domain.ProjectConfig) *domain.ProjectConfig { + if projectConfig.IsZero() { + return nil + } + cfg := projectConfig + return &cfg +} + func displayName(row domain.ProjectRecord) string { if strings.TrimSpace(row.DisplayName) != "" { return row.DisplayName diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index f10dba67e..48e66a653 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -436,6 +436,32 @@ func TestManager_SetConfig(t *testing.T) { wantCode(t, err, "PROJECT_NOT_FOUND") } +func TestManager_ListIncludesOnlySummarySafeProjectConfig(t *testing.T) { + ctx := context.Background() + m := newManager(t) + repo := gitRepo(t) + + cfg := domain.ProjectConfig{ + DefaultBranch: "develop", + Env: map[string]string{"GITHUB_TOKEN": "secret"}, + Orchestrator: domain.RoleOverride{Harness: domain.HarnessCodex}, + } + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao"), Config: &cfg}); err != nil { + t.Fatalf("Add: %v", err) + } + + list, err := m.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 1 { + t.Fatalf("List len = %d, want 1", len(list)) + } + if list[0].OrchestratorAgent != domain.HarnessCodex { + t.Fatalf("summary orchestrator agent = %q, want codex", list[0].OrchestratorAgent) + } +} + func TestManager_ReaddAfterRemove(t *testing.T) { ctx := context.Background() m := newManager(t) diff --git a/backend/internal/service/project/types.go b/backend/internal/service/project/types.go index f33b338d5..81e5da2be 100644 --- a/backend/internal/service/project/types.go +++ b/backend/internal/service/project/types.go @@ -4,12 +4,13 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain" // Summary is the row shape returned by GET /api/v1/projects. type Summary struct { - ID domain.ProjectID `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - Kind domain.ProjectKind `json:"kind"` - SessionPrefix string `json:"sessionPrefix"` - ResolveError string `json:"resolveError,omitempty"` + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Kind domain.ProjectKind `json:"kind"` + SessionPrefix string `json:"sessionPrefix"` + OrchestratorAgent domain.AgentHarness `json:"orchestratorAgent,omitempty"` + ResolveError string `json:"resolveError,omitempty"` } // Project is the full read-model returned by GET /api/v1/projects/{id}. diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 32d338897..0851f3e37 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -45,6 +46,7 @@ type commander interface { Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error) Kill(ctx context.Context, id domain.SessionID) (bool, error) + RetireForReplacement(ctx context.Context, id domain.SessionID) error Send(ctx context.Context, id domain.SessionID, message string) error Cleanup(ctx context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) RollbackSpawn(ctx context.Context, id domain.SessionID) (deleted, killed bool, err error) @@ -81,12 +83,14 @@ type scmProvider interface { // session operations to the internal sessionmanager.Manager and owns read-model // assembly, including user-facing display status derivation. type Service struct { - manager commander - store Store - prClaimer ports.PRClaimer - scm scmProvider - clock func() time.Time - telemetry ports.EventSink + manager commander + store Store + prClaimer ports.PRClaimer + scm scmProvider + clock func() time.Time + telemetry ports.EventSink + orchestratorLocksMu sync.Mutex + orchestratorLocks map[domain.ProjectID]*sync.Mutex // signalCapable reports whether a harness has a hook pipeline that can // deliver activity signals at all. Only capable harnesses are eligible for // the no_signal downgrade: a hook-less harness staying silent forever is @@ -263,6 +267,13 @@ func (s *Service) emitSpawnFailed(cfg ports.SpawnConfig, err error, durationMs i // active orchestrator already exists it is returned as-is. A business rule that // belongs here, not in the HTTP controller. func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) { + unlock := s.lockOrchestratorProject(projectID) + defer unlock() + + project, err := s.requireProject(ctx, projectID) + if err != nil { + return domain.Session{}, err + } active := true if clean { existing, err := s.List(ctx, ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true}) @@ -270,8 +281,9 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec return domain.Session{}, err } for _, orch := range existing { - if _, err := s.Kill(ctx, orch.ID); err != nil { - return domain.Session{}, err + _ = s.sendRetireNotice(ctx, orch.ID) + if err := s.manager.RetireForReplacement(ctx, orch.ID); err != nil { + return domain.Session{}, toAPIError(err) } } } else { @@ -281,10 +293,90 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec return domain.Session{}, err } if len(existing) > 0 { - return existing[0], nil + return newestSession(existing), nil } } - return s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) + sess, err := s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) + if err != nil { + return domain.Session{}, err + } + if err := verifyOrchestratorReplacement(project, sess); err != nil { + return domain.Session{}, err + } + return sess, nil +} + +const orchestratorRetireNotice = "AO is replacing this project orchestrator. Stop coordinating new work now; a fresh orchestrator will take over on the canonical branch." + +func (s *Service) sendRetireNotice(ctx context.Context, id domain.SessionID) error { + if err := s.manager.Send(ctx, id, orchestratorRetireNotice); err != nil { + return fmt.Errorf("send retire notice to %s: %w", id, err) + } + return nil +} + +func verifyOrchestratorReplacement(project domain.ProjectRecord, sess domain.Session) error { + if sess.IsTerminated { + return fmt.Errorf("orchestrator replacement verification failed: new session %s is terminated", sess.ID) + } + if sess.Kind != domain.KindOrchestrator { + return fmt.Errorf("orchestrator replacement verification failed: new session %s has kind %q", sess.ID, sess.Kind) + } + if expected := project.Config.Orchestrator.Harness; expected != "" && sess.Harness != expected { + return fmt.Errorf("orchestrator replacement verification failed: new session %s uses harness %q, want %q", sess.ID, sess.Harness, expected) + } + expectedBranch := "ao/" + serviceSessionPrefix(project) + "-orchestrator" + if sess.Metadata.Branch != "" && sess.Metadata.Branch != expectedBranch { + return fmt.Errorf("orchestrator replacement verification failed: new session %s uses branch %q, want %q", sess.ID, sess.Metadata.Branch, expectedBranch) + } + return nil +} + +func serviceSessionPrefix(project domain.ProjectRecord) string { + if p := strings.TrimSpace(project.Config.SessionPrefix); p != "" { + return p + } + id := project.ID + if len(id) <= 12 { + return id + } + return id[:12] +} + +func newestSession(sessions []domain.Session) domain.Session { + newest := sessions[0] + for _, sess := range sessions[1:] { + if sessionNewer(sess.SessionRecord, newest.SessionRecord) { + newest = sess + } + } + return newest +} + +func sessionNewer(a, b domain.SessionRecord) bool { + if !a.CreatedAt.Equal(b.CreatedAt) { + return a.CreatedAt.After(b.CreatedAt) + } + if !a.UpdatedAt.Equal(b.UpdatedAt) { + return a.UpdatedAt.After(b.UpdatedAt) + } + return string(a.ID) > string(b.ID) +} + +func (s *Service) lockOrchestratorProject(projectID domain.ProjectID) func() { + s.orchestratorLocksMu.Lock() + if s.orchestratorLocks == nil { + s.orchestratorLocks = make(map[domain.ProjectID]*sync.Mutex) + } + mu := s.orchestratorLocks[projectID] + if mu == nil { + mu = &sync.Mutex{} + s.orchestratorLocks[projectID] = mu + } + s.orchestratorLocksMu.Unlock() + + mu.Lock() + return mu.Unlock } // Restore relaunches a terminated session and returns the API-facing read model. diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index c5b1b1ca6..9dea20257 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "testing" "time" @@ -201,10 +202,15 @@ func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) { // clean-orchestrator ordering without wiring a real session engine. type fakeCommander struct { killed []domain.SessionID + retired []domain.SessionID + sent []domain.SessionID cleanupProjects []domain.ProjectID killErr error + retireErr error + sendErr error cleanupErr error spawnErr error + spawnRecord domain.SessionRecord spawned bool killsAtSpawn int } @@ -214,7 +220,10 @@ func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain. return domain.SessionRecord{}, f.spawnErr } f.spawned = true - f.killsAtSpawn = len(f.killed) + f.killsAtSpawn = len(f.retired) + if f.spawnRecord.ID != "" { + return f.spawnRecord, nil + } return domain.SessionRecord{ID: "mer-9", ProjectID: cfg.ProjectID, Kind: cfg.Kind, Harness: cfg.Harness}, nil } func (f *fakeCommander) Restore(context.Context, domain.SessionID) (domain.SessionRecord, error) { @@ -227,7 +236,20 @@ func (f *fakeCommander) Kill(_ context.Context, id domain.SessionID) (bool, erro f.killed = append(f.killed, id) return true, nil } -func (f *fakeCommander) Send(context.Context, domain.SessionID, string) error { return nil } +func (f *fakeCommander) RetireForReplacement(_ context.Context, id domain.SessionID) error { + if f.retireErr != nil { + return f.retireErr + } + f.retired = append(f.retired, id) + return nil +} +func (f *fakeCommander) Send(_ context.Context, id domain.SessionID, _ string) error { + if f.sendErr != nil { + return f.sendErr + } + f.sent = append(f.sent, id) + return nil +} func (f *fakeCommander) Cleanup(_ context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) { f.cleanupProjects = append(f.cleanupProjects, project) if f.cleanupErr != nil { @@ -293,7 +315,7 @@ func TestTeardownProjectStopsOnKillError(t *testing.T) { } } -func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) { +func TestSpawnOrchestratorCleanRetiresActiveOrchestratorsBeforeSpawn(t *testing.T) { st := newFakeStore() st.projects["mer"] = domain.ProjectRecord{ID: "mer"} // Two active orchestrators plus an unrelated worker and a terminated @@ -310,11 +332,35 @@ func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) t.Fatalf("SpawnOrchestrator: %v", err) } - if len(fc.killed) != 2 { - t.Fatalf("killed = %v, want the two active orchestrators", fc.killed) + if len(fc.retired) != 2 { + t.Fatalf("retired = %v, want the two active orchestrators", fc.retired) + } + if len(fc.sent) != 2 { + t.Fatalf("retire notices = %v, want the two active orchestrators", fc.sent) } if !fc.spawned || fc.killsAtSpawn != 2 { - t.Fatalf("spawn must run after both kills: spawned=%v killsAtSpawn=%d", fc.spawned, fc.killsAtSpawn) + t.Fatalf("spawn must run after both retirements: spawned=%v retirementsAtSpawn=%d", fc.spawned, fc.killsAtSpawn) + } + if len(fc.killed) != 0 { + t.Fatalf("interactive Kill must not be used for replacement: killed=%v", fc.killed) + } +} + +func TestSpawnOrchestratorCleanContinuesWhenRetireNoticeFails(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer"} + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator} + fc := &fakeCommander{sendErr: errors.New("pane closed")} + svc := &Service{manager: fc, store: st} + + if _, err := svc.SpawnOrchestrator(context.Background(), "mer", true); err != nil { + t.Fatalf("SpawnOrchestrator: %v", err) + } + if len(fc.retired) != 1 || fc.retired[0] != "mer-1" { + t.Fatalf("retired = %v, want mer-1 despite retire notice failure", fc.retired) + } + if !fc.spawned { + t.Fatal("replacement should still spawn when retire notice delivery fails") } } @@ -620,6 +666,29 @@ func TestSpawnOrchestratorNoCleanSpawnsWhenNoneExists(t *testing.T) { } } +func TestSpawnOrchestratorVerifiesReplacementHarness(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ + ID: "mer", + Config: domain.ProjectConfig{Orchestrator: domain.RoleOverride{Harness: domain.HarnessCodex}}, + } + fc := &fakeCommander{ + spawnRecord: domain.SessionRecord{ + ID: "mer-9", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Harness: domain.HarnessClaudeCode, + Metadata: domain.SessionMetadata{Branch: "ao/mer-orchestrator"}, + }, + } + svc := &Service{manager: fc, store: st} + + _, err := svc.SpawnOrchestrator(context.Background(), "mer", false) + if err == nil || !strings.Contains(err.Error(), `uses harness "claude-code", want "codex"`) { + t.Fatalf("SpawnOrchestrator err = %v, want harness verification failure", err) + } +} + type fakePRClaimer struct { out errorFreeClaimOutcome err error diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 9fc3af565..11da9edd8 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -476,6 +476,59 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { return freed, nil } +// RetireForReplacement terminates a live orchestrator and releases its branch +// for a replacement session. Unlike Kill, this captures uncommitted work before +// force-removing the worktree, so a dirty canonical orchestrator worktree does +// not block the replacement from claiming the canonical branch. +// +// This deliberately does not write a session_worktrees row: those rows are +// boot-restore markers, and a replaced orchestrator must stay terminated. +func (m *Manager) RetireForReplacement(ctx context.Context, id domain.SessionID) error { + rec, ok, err := m.store.GetSession(ctx, id) + if err != nil { + return fmt.Errorf("retire replacement %s: %w", id, err) + } + if !ok || rec.IsTerminated { + return nil + } + if rec.Metadata.WorkspacePath == "" || rec.Metadata.Branch == "" { + if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil { + return fmt.Errorf("retire replacement %s: clear restore markers: %w", id, err) + } + handle := runtimeHandle(rec.Metadata) + if handle.ID != "" { + if err := m.runtime.Destroy(ctx, handle); err != nil { + return fmt.Errorf("retire replacement %s: runtime: %w", id, err) + } + } + if err := m.lcm.MarkTerminated(ctx, id); err != nil { + return fmt.Errorf("retire replacement %s: mark terminated: %w", id, err) + } + return nil + } + + ws := workspaceInfo(rec) + if _, err := m.workspace.StashUncommitted(ctx, ws); err != nil { + return fmt.Errorf("retire replacement %s: stash: %w", id, err) + } + if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil { + return fmt.Errorf("retire replacement %s: clear restore markers: %w", id, err) + } + handle := runtimeHandle(rec.Metadata) + if handle.ID != "" { + if err := m.runtime.Destroy(ctx, handle); err != nil { + return fmt.Errorf("retire replacement %s: runtime: %w", id, err) + } + } + if err := m.workspace.ForceDestroy(ctx, ws); err != nil { + return fmt.Errorf("retire replacement %s: force destroy: %w", id, err) + } + if err := m.lcm.MarkTerminated(ctx, rec.ID); err != nil { + return fmt.Errorf("retire replacement %s: mark terminated: %w", id, err) + } + return nil +} + // Restore relaunches a torn-down session in its workspace. The fallible I/O runs // before any durable session write, so a failure never resurrects the row or destroys // the worktree (it may hold the agent's prior work). diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 679f7a6e5..4b290885d 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -114,6 +114,9 @@ func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID) return f.worktrees[id], nil } func (f *fakeStore) DeleteSessionWorktrees(_ context.Context, id domain.SessionID) error { + if f.sharedLog != nil { + *f.sharedLog = append(*f.sharedLog, "DeleteSessionWorktrees:"+string(id)) + } delete(f.worktrees, id) return nil } @@ -148,6 +151,7 @@ func (l *fakeLCM) MarkTerminated(_ context.Context, id domain.SessionID) error { type fakeRuntime struct { createErr error + destroyErr error created, destroyed int lastCfg ports.RuntimeConfig // aliveByHandle maps a RuntimeHandle.ID to its liveness; missing = false. @@ -167,7 +171,7 @@ func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports. func (r *fakeRuntime) Destroy(_ context.Context, handle ports.RuntimeHandle) error { r.destroyed++ r.destroyedIDs = append(r.destroyedIDs, handle.ID) - return nil + return r.destroyErr } func (r *fakeRuntime) IsAlive(_ context.Context, handle ports.RuntimeHandle) (bool, error) { if r.aliveErr != nil { @@ -1427,6 +1431,130 @@ func TestSaveAndTeardownAll_CaptureOrderAndMarker(t *testing.T) { } } +func TestRetireForReplacementCapturesAndReleasesWorkspace(t *testing.T) { + m, st, rt, ws := newLifecycleManager() + var sharedLog []string + st.sharedLog = &sharedLog + ws.sharedLog = &sharedLog + ws.stashRef = "refs/ao/preserved/mer-orch" + st.sessions["mer-orch"] = domain.SessionRecord{ + ID: "mer-orch", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{ + SessionID: "mer-orch", + RepoName: domain.RootWorkspaceRepoName, + Branch: "ao/mer-orchestrator", + WorktreePath: "/ws/mer-orch", + PreservedRef: "refs/ao/preserved/old", + }} + + if err := m.RetireForReplacement(ctx, "mer-orch"); err != nil { + t.Fatalf("RetireForReplacement err = %v", err) + } + + if rows := st.worktrees["mer-orch"]; len(rows) != 0 { + t.Fatalf("replacement retirement must not write restore markers, got %#v", rows) + } + if !st.sessions["mer-orch"].IsTerminated { + t.Fatal("retired orchestrator must be marked terminated") + } + if rt.destroyed != 1 || rt.destroyedIDs[0] != "orch-handle" { + t.Fatalf("runtime destroyed = %d ids=%v, want orch-handle", rt.destroyed, rt.destroyedIDs) + } + + stashIdx, deleteIdx, forceIdx := -1, -1, -1 + for i, c := range sharedLog { + switch c { + case "StashUncommitted:mer-orch": + stashIdx = i + case "DeleteSessionWorktrees:mer-orch": + deleteIdx = i + case "ForceDestroy:mer-orch": + forceIdx = i + } + } + if stashIdx == -1 || deleteIdx == -1 || forceIdx == -1 { + t.Fatalf("missing expected calls in shared log: %v", sharedLog) + } + if stashIdx >= deleteIdx || deleteIdx >= forceIdx { + t.Fatalf("replacement retire must capture, clear restore marker, then force release; log=%v", sharedLog) + } +} + +func TestRetireForReplacementForceDestroyFailureLeavesSessionActive(t *testing.T) { + m, st, rt, ws := newLifecycleManager() + ws.forceDestroyErr = errors.New("worktree still registered") + ws.stashRef = "refs/ao/preserved/mer-orch" + st.sessions["mer-orch"] = domain.SessionRecord{ + ID: "mer-orch", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{ + SessionID: "mer-orch", + RepoName: domain.RootWorkspaceRepoName, + Branch: "ao/mer-orchestrator", + WorktreePath: "/ws/mer-orch", + PreservedRef: "refs/ao/preserved/old", + }} + + err := m.RetireForReplacement(ctx, "mer-orch") + if err == nil || !strings.Contains(err.Error(), "force destroy") { + t.Fatalf("RetireForReplacement err = %v, want force destroy failure", err) + } + if st.sessions["mer-orch"].IsTerminated { + t.Fatal("session must remain active so retry can retire it again") + } + if rt.destroyed != 1 { + t.Fatalf("runtime destroyed = %d, want 1 before workspace release", rt.destroyed) + } + if ws.stashCalls != 1 { + t.Fatalf("stash calls = %d, want 1", ws.stashCalls) + } +} + +func TestRetireForReplacementRuntimeDestroyFailureBlocksWorkspaceRelease(t *testing.T) { + m, st, rt, ws := newLifecycleManager() + rt.destroyErr = errors.New("tmux transient") + ws.stashRef = "refs/ao/preserved/mer-orch" + st.sessions["mer-orch"] = domain.SessionRecord{ + ID: "mer-orch", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{ + SessionID: "mer-orch", + RepoName: domain.RootWorkspaceRepoName, + Branch: "ao/mer-orchestrator", + WorktreePath: "/ws/mer-orch", + PreservedRef: "refs/ao/preserved/old", + }} + + err := m.RetireForReplacement(ctx, "mer-orch") + if err == nil || !strings.Contains(err.Error(), "runtime") { + t.Fatalf("RetireForReplacement err = %v, want runtime failure", err) + } + if st.sessions["mer-orch"].IsTerminated { + t.Fatal("session must remain active when runtime destroy fails") + } + if rt.destroyed != 1 || rt.destroyedIDs[0] != "orch-handle" { + t.Fatalf("runtime destroyed = %d ids=%v, want one attempt for orch-handle", rt.destroyed, rt.destroyedIDs) + } + for _, call := range ws.calls { + if call == "ForceDestroy:mer-orch" { + t.Fatalf("ForceDestroy must not run after runtime destroy failure; calls=%v", ws.calls) + } + } +} + // TestSaveAndTeardownAll_CleanWorktreeWritesEmptyRef verifies that a clean // worktree (StashUncommitted returns "") still writes a worktree row (with // empty preserved_ref). The row's presence is the shutdown-saved marker. diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index fe960f8e0..294de0c00 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -746,6 +746,7 @@ export interface components { id: string; kind: string; name: string; + orchestratorAgent?: string; path: string; resolveError?: string; sessionPrefix: string; diff --git a/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx b/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx new file mode 100644 index 000000000..c9f3817e6 --- /dev/null +++ b/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx @@ -0,0 +1,75 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { useNavigate } from "@tanstack/react-router"; +import { AlertTriangle, RotateCw, X } from "lucide-react"; +import { findProjectOrchestrator, type WorkspaceSummary } from "../types/workspace"; + +type OrchestratorReplacementDialogProps = { + projectId: string | null; + error?: string; + workspaces: WorkspaceSummary[]; + onOpenChange: (open: boolean) => void; + onRetry: (projectId: string) => void; +}; + +export function OrchestratorReplacementDialog({ + projectId, + error, + workspaces, + onOpenChange, + onRetry, +}: OrchestratorReplacementDialogProps) { + const navigate = useNavigate(); + const open = Boolean(projectId && error); + const orchestrator = projectId ? findProjectOrchestrator(workspaces, projectId) : undefined; + + const openCurrent = () => { + if (!projectId || !orchestrator) return; + onOpenChange(false); + void navigate({ + to: "/projects/$projectId/sessions/$sessionId", + params: { projectId, sessionId: orchestrator.id }, + }); + }; + + return ( + + + + +
+
+
+
+ Orchestrator replacement failed + + {error ?? "The project orchestrator could not be replaced."} + +
+ + + +
+
+ {orchestrator ? ( + + ) : null} + +
+
+
+
+ ); +} diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index e1a58e340..8811eefb9 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -3,15 +3,17 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { getMock, putMock } = vi.hoisted(() => ({ +const { getMock, putMock, postMock } = vi.hoisted(() => ({ getMock: vi.fn(), putMock: vi.fn(), + postMock: vi.fn(), })); vi.mock("../lib/api-client", () => ({ apiClient: { GET: getMock, PUT: putMock, + POST: postMock, }, apiErrorMessage: (error: unknown) => { if (error instanceof Error) return error.message; @@ -23,14 +25,19 @@ vi.mock("../lib/api-client", () => ({ })); import { ProjectSettingsForm } from "./ProjectSettingsForm"; +import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import type { WorkspaceSummary } from "../types/workspace"; -function renderSettings(projectId = "proj-1") { +function renderSettings(projectId = "proj-1", workspaces?: WorkspaceSummary[]) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); + if (workspaces) { + queryClient.setQueryData(workspaceQueryKey, workspaces); + } render( @@ -86,7 +93,9 @@ function mockProject(project: Record) { beforeEach(() => { getMock.mockReset(); putMock.mockReset(); + postMock.mockReset(); putMock.mockResolvedValue({ data: { project: {} }, error: undefined }); + postMock.mockResolvedValue({ data: { orchestrator: { id: "proj-1-orch-2" } }, error: undefined, response: { status: 200 } }); }); describe("ProjectSettingsForm", () => { @@ -168,6 +177,10 @@ describe("ProjectSettingsForm", () => { }, }, }); + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(postMock).toHaveBeenCalledWith("/api/v1/orchestrators", { + body: { projectId: "proj-1", clean: true }, + }); expect(await screen.findByText("Saved.")).toBeInTheDocument(); }, 20_000); @@ -195,6 +208,7 @@ describe("ProjectSettingsForm", () => { expect(await screen.findByText("invalid permissions")).toBeInTheDocument(); expect(screen.queryByText("Saved.")).not.toBeInTheDocument(); + expect(postMock).not.toHaveBeenCalled(); }); it("requires worker and orchestrator agents for existing projects missing role config", async () => { @@ -323,4 +337,101 @@ describe("ProjectSettingsForm", () => { expect(await screen.findAllByText("Enabling intake requires an assignee.")).toHaveLength(2); expect(putMock).not.toHaveBeenCalled(); }); + + it("restarts when the saved orchestrator agent already differs from the running orchestrator", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "goose" }, + }, + }, + }, + error: undefined, + }); + + renderSettings("proj-1", [ + { + id: "proj-1", + name: "Project One", + path: "/repo/project-one", + orchestratorAgent: "goose", + sessions: [ + { + id: "proj-1-orchestrator", + workspaceId: "proj-1", + workspaceName: "Project One", + title: "Orchestrator", + provider: "claude-code", + kind: "orchestrator", + branch: "ao/proj-1-orchestrator", + status: "working", + createdAt: "2026-07-03T00:00:00Z", + updatedAt: "2026-07-03T00:00:00Z", + prs: [], + }, + ], + }, + ]); + + const orchestratorAgent = await screen.findByRole("combobox", { name: "Default orchestrator agent" }); + expect(orchestratorAgent).toHaveTextContent("goose"); + + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(postMock).toHaveBeenCalledWith("/api/v1/orchestrators", { + body: { projectId: "proj-1", clean: true }, + }); + }); + + it("keeps the config save successful when orchestrator replacement fails", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, + }, + }, + }, + error: undefined, + }); + postMock.mockResolvedValue({ + data: undefined, + error: { message: "missing goose binary" }, + response: { status: 500 }, + }); + + const queryClient = renderSettings(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const orchestratorAgent = await screen.findByRole("combobox", { name: "Default orchestrator agent" }); + await chooseOption(orchestratorAgent, "goose"); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(await screen.findByText("Saved.")).toBeInTheDocument(); + expect(await screen.findByText("Orchestrator restart failed: missing goose binary")).toBeInTheDocument(); + expect(screen.queryByText("Save failed")).not.toBeInTheDocument(); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["project", "proj-1"] }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey }); + }); }); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index d50216dca..513567027 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -1,9 +1,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import type { components } from "../../api/schema"; -import { apiClient, apiErrorMessage } from "../lib/api-client"; import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; -import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { newestActiveOrchestrator } from "../types/workspace"; import { RequiredAgentField } from "./CreateProjectAgentSheet"; import { DashboardSubhead } from "./DashboardSubhead"; import { buildIntake, deriveGitHubRepo, IntakeFields, type IntakeForm, intakeNeedsRule } from "./IntakeFields"; @@ -68,7 +70,10 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) { function SettingsBody({ project, projectId, onSaved }: { project: Project; projectId: string; onSaved: () => void }) { const queryClient = useQueryClient(); + const workspaceQuery = useWorkspaceQuery(); const config = project.config ?? {}; + const workspace = workspaceQuery.data?.find((item) => item.id === projectId); + const activeOrchestrator = newestActiveOrchestrator(workspace?.sessions ?? []); const intake: TrackerIntakeConfig = config.trackerIntake ?? {}; const [form, setForm] = useState({ defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "", @@ -83,7 +88,9 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje intakeAssignee: intake.assignee ?? "", }); const [savedAt, setSavedAt] = useState(null); + const [replacementError, setReplacementError] = useState(null); const [validationError, setValidationError] = useState(null); + const initialOrchestratorAgent = config.orchestrator?.agent ?? ""; const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === ""; const agentsQuery = useQuery(agentsQueryOptions); const agentCatalog = agentsQuery.data; @@ -134,9 +141,23 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje body: { config: next }, }); if (error) throw new Error(apiErrorMessage(error)); + if ( + form.orchestratorAgent !== initialOrchestratorAgent || + (activeOrchestrator && activeOrchestrator.provider !== form.orchestratorAgent) + ) { + try { + await spawnOrchestrator(projectId, true); + } catch (error) { + return { + replacementError: error instanceof Error ? error.message : "Could not replace orchestrator", + }; + } + } + return { replacementError: null }; }, - onSuccess: () => { + onSuccess: (result) => { setSavedAt(Date.now()); + setReplacementError(result.replacementError); setValidationError(null); void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); onSaved(); @@ -149,6 +170,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje onSubmit={(event) => { event.preventDefault(); setSavedAt(null); + setReplacementError(null); if (missingRequiredAgent) { setValidationError("Worker and orchestrator agents are required."); return; @@ -304,6 +326,9 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje {savedAt && !mutation.isPending && !mutation.isError && ( Saved. )} + {replacementError && !mutation.isPending && !mutation.isError && ( + Orchestrator restart failed: {replacementError} + )}
); diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 6e85b89fa..30f4fde97 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -1,14 +1,15 @@ import { type KeyboardEvent, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; - +import { AlertTriangle, Plus, RotateCw } from "lucide-react"; import { DashboardSubhead } from "./DashboardSubhead"; -import { Plus } from "lucide-react"; import { type AttentionZone, type WorkspaceSession, attentionZone, canonicalTrackerIssueId, + newestActiveOrchestrator, + orchestratorHealth, workerSessions, } from "../types/workspace"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; @@ -16,9 +17,11 @@ import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery import { OrchestratorIcon } from "./icons"; import { NewTaskDialog } from "./NewTaskDialog"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { restartProjectOrchestrator } from "../lib/restart-orchestrator"; import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display"; import { cn } from "../lib/utils"; import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay"; +import { useUiStore } from "../stores/ui-store"; type SessionsBoardProps = { /** When set, the board shows only this project's sessions. */ @@ -77,12 +80,16 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { const workspaceQuery = useWorkspaceQuery(); const all = workspaceQuery.data ?? []; const workspaces = projectId ? all.filter((w) => w.id === projectId) : all; + const workspace = projectId ? workspaces[0] : undefined; const sessions = workspaces.flatMap((w) => workerSessions(w.sessions)); - const orchestrator = projectId - ? workspaces[0]?.sessions.find((session) => session.kind === "orchestrator" && session.status !== "terminated") - : undefined; + const orchestrator = projectId ? newestActiveOrchestrator(workspaces[0]?.sessions ?? []) : undefined; const [isNewTaskOpen, setIsNewTaskOpen] = useState(false); const [isSpawning, setIsSpawning] = useState(false); + const restartingProjectIds = useUiStore((state) => state.restartingProjectIds); + const setProjectRestarting = useUiStore((state) => state.setProjectRestarting); + const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError); + const isProjectRestarting = projectId ? restartingProjectIds.has(projectId) : false; + const health = workspace ? orchestratorHealth(workspace, isProjectRestarting) : { state: "ok" as const }; const byZone = new Map(); for (const session of sessions) { @@ -101,7 +108,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { }); const openOrchestrator = async () => { - if (!projectId) return; + if (!projectId || isProjectRestarting) return; if (orchestrator) { void navigate({ to: "/projects/$projectId/sessions/$sessionId", @@ -122,6 +129,17 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { } }; + const restartOrchestrator = async () => { + if (!projectId) return; + await restartProjectOrchestrator({ + projectId, + queryClient, + navigate, + setProjectRestarting, + setOrchestratorReplacementError, + }); + }; + const handleTaskCreated = async (sessionId: string) => { if (!projectId) return; await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); @@ -136,6 +154,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { ) : undefined; @@ -164,6 +183,23 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { />
+ {projectId && health.state !== "ok" ? ( +
+
+ ) : null} {workspaceQuery.isError ? (

Could not load sessions.

) : ( diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index c336b19cb..2929475c7 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -55,6 +55,7 @@ export function ShellTopbar() { const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string }; const isInspectorOpen = useUiStore((state) => state.isInspectorOpen); const toggleInspector = useUiStore((state) => state.toggleInspector); + const restartingProjectIds = useUiStore((state) => state.restartingProjectIds); const [isSpawning, setIsSpawning] = useState(false); const [isNewTaskOpen, setIsNewTaskOpen] = useState(false); const all = useWorkspaceQuery().data ?? []; @@ -74,17 +75,18 @@ export function ShellTopbar() { const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined; const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator"); const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined; + const isProjectRestarting = projectId ? restartingProjectIds.has(projectId) : false; const openBoard = () => projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" }); const openNewTask = () => { - if (!projectId) return; + if (!projectId || isProjectRestarting) return; setIsNewTaskOpen(true); }; const handleTaskCreated = async (sessionId: string) => { - if (!projectId) return; + if (!projectId || isProjectRestarting) return; await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); void navigate({ to: "/projects/$projectId/sessions/$sessionId", @@ -171,6 +173,7 @@ export function ShellTopbar() { )} {/* Inspector collapse (worker sessions only — orchestrators have no rail). */} diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index e1200349f..09923768c 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -16,7 +16,7 @@ import { import { useRef, useState, type ReactNode } from "react"; import { attentionZone, - isOrchestratorSession, + newestActiveOrchestrator, sessionIsActive, type WorkspaceSession, type WorkspaceSummary, @@ -426,16 +426,19 @@ function ProjectItem({ const [removeError, setRemoveError] = useState(null); const [isRemoving, setIsRemoving] = useState(false); const [isSpawning, setIsSpawning] = useState(false); + const restartingProjectIds = useUiStore((state) => state.restartingProjectIds); + const isProjectRestarting = restartingProjectIds.has(workspace.id); // Live workers only: merged/terminated sessions leave the sidebar and stay // reachable through the board's Done / Terminated bar (SessionsBoard). const sessions = workerSessions(workspace.sessions).filter(sessionIsActive); // The project's live orchestrator (if any) backs the hover Orchestrator // button: navigate to it when present, otherwise spawn one first. - const orchestrator = workspace.sessions.find((s) => isOrchestratorSession(s) && sessionIsActive(s)); + const orchestrator = newestActiveOrchestrator(workspace.sessions); // Mirrors ShellTopbar's launcher: attach to the running orchestrator, or // spawn one via the daemon and follow it once the workspace refetches. const openOrchestrator = async () => { + if (isProjectRestarting) return; if (orchestrator) { selection.goSession(workspace.id, orchestrator.id); return; @@ -546,7 +549,7 @@ function ProjectItem({ - {isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"} + {isProjectRestarting ? "Restarting…" : isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"} diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index 21b402872..60fc3c4e8 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -51,7 +51,16 @@ describe("useWorkspaceQuery", () => { it("maps projects and their sessions, applying provider/status/title fallbacks", async () => { respondWith({ projects: { - data: { projects: [{ id: "proj-1", name: "my-app", path: "/home/me/my-app" }] }, + data: { + projects: [ + { + id: "proj-1", + name: "my-app", + path: "/home/me/my-app", + orchestratorAgent: "codex", + }, + ], + }, error: undefined, }, sessions: { @@ -92,7 +101,7 @@ describe("useWorkspaceQuery", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); const [workspace] = result.current.data ?? []; - expect(workspace).toMatchObject({ id: "proj-1", name: "my-app", path: "/home/me/my-app" }); + expect(workspace).toMatchObject({ id: "proj-1", name: "my-app", path: "/home/me/my-app", orchestratorAgent: "codex" }); expect(workspace.sessions).toHaveLength(2); expect(workspace.sessions[0]).toMatchObject({ id: "sess-1", diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index b46373aff..479323c78 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -44,6 +44,7 @@ async function fetchWorkspaces(): Promise { id: project.id, name: project.name, path: project.path, + orchestratorAgent: project.orchestratorAgent ? toAgentProvider(project.orchestratorAgent) : undefined, sessions: (sessionsData?.sessions ?? []) .filter((session) => session.projectId === project.id) .map((session) => ({ diff --git a/frontend/src/renderer/lib/orchestrator-replacement-telemetry.test.ts b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.test.ts new file mode 100644 index 000000000..e56be81e1 --- /dev/null +++ b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; + +const { captureRendererExceptionMock } = vi.hoisted(() => ({ + captureRendererExceptionMock: vi.fn(), +})); + +vi.mock("./telemetry", () => ({ + captureRendererException: captureRendererExceptionMock, +})); + +import { captureOrchestratorReplacementFailure } from "./orchestrator-replacement-telemetry"; + +describe("captureOrchestratorReplacementFailure", () => { + it("records the shell restart-failure telemetry payload", () => { + const error = new Error("missing goose binary"); + + captureOrchestratorReplacementFailure(error, "proj-1"); + + expect(captureRendererExceptionMock).toHaveBeenCalledWith(error, { + source: "orchestrator-replace", + operation: "replace_orchestrator", + surface: "shell", + project_id: "proj-1", + }); + }); +}); diff --git a/frontend/src/renderer/lib/orchestrator-replacement-telemetry.ts b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.ts new file mode 100644 index 000000000..64e28a826 --- /dev/null +++ b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.ts @@ -0,0 +1,10 @@ +import { captureRendererException } from "./telemetry"; + +export function captureOrchestratorReplacementFailure(error: unknown, projectId: string) { + void captureRendererException(error, { + source: "orchestrator-replace", + operation: "replace_orchestrator", + surface: "shell", + project_id: projectId, + }); +} diff --git a/frontend/src/renderer/lib/restart-orchestrator.test.ts b/frontend/src/renderer/lib/restart-orchestrator.test.ts new file mode 100644 index 000000000..8b85b9bf8 --- /dev/null +++ b/frontend/src/renderer/lib/restart-orchestrator.test.ts @@ -0,0 +1,73 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), +})); + +vi.mock("./spawn-orchestrator", () => ({ + spawnOrchestrator: spawnMock, +})); + +import { restartProjectOrchestrator } from "./restart-orchestrator"; + +describe("restartProjectOrchestrator", () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + it("invalidates workspace state and records an error when clean restart fails", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue(); + const navigate = vi.fn(); + const setProjectRestarting = vi.fn(); + const setOrchestratorReplacementError = vi.fn(); + const onError = vi.fn(); + const failure = new Error("missing goose binary"); + spawnMock.mockRejectedValue(failure); + + await restartProjectOrchestrator({ + projectId: "proj-1", + queryClient, + navigate, + setProjectRestarting, + setOrchestratorReplacementError, + onError, + }); + + expect(spawnMock).toHaveBeenCalledWith("proj-1", true); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey }); + expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(1, "proj-1", null); + expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(2, "proj-1", "missing goose binary"); + expect(setProjectRestarting).toHaveBeenNthCalledWith(1, "proj-1", true); + expect(setProjectRestarting).toHaveBeenLastCalledWith("proj-1", false); + expect(onError).toHaveBeenCalledWith(failure); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("still records the replacement error when workspace invalidation fails", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.spyOn(queryClient, "invalidateQueries").mockRejectedValue(new Error("refetch failed")); + const navigate = vi.fn(); + const setProjectRestarting = vi.fn(); + const setOrchestratorReplacementError = vi.fn(); + const onError = vi.fn(); + const failure = new Error("missing goose binary"); + spawnMock.mockRejectedValue(failure); + + await restartProjectOrchestrator({ + projectId: "proj-1", + queryClient, + navigate, + setProjectRestarting, + setOrchestratorReplacementError, + onError, + }); + + expect(setOrchestratorReplacementError).toHaveBeenLastCalledWith("proj-1", "missing goose binary"); + expect(setProjectRestarting).toHaveBeenLastCalledWith("proj-1", false); + expect(onError).toHaveBeenCalledWith(failure); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/renderer/lib/restart-orchestrator.ts b/frontend/src/renderer/lib/restart-orchestrator.ts new file mode 100644 index 000000000..0d41f7981 --- /dev/null +++ b/frontend/src/renderer/lib/restart-orchestrator.ts @@ -0,0 +1,52 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import { spawnOrchestrator } from "./spawn-orchestrator"; + +type NavigateToSession = (options: { + to: "/projects/$projectId/sessions/$sessionId"; + params: { projectId: string; sessionId: string }; +}) => unknown; + +type RestartProjectOrchestratorOptions = { + projectId: string; + queryClient: QueryClient; + navigate: NavigateToSession; + setProjectRestarting: (projectId: string, restarting: boolean) => void; + setOrchestratorReplacementError: (projectId: string, message: string | null) => void; + onError?: (error: unknown) => void; +}; + +async function refreshWorkspaceState(queryClient: QueryClient) { + try { + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + } catch { + // The restart outcome is more important than cache refresh bookkeeping: + // callers still need navigation/error state even if refetching fails. + } +} + +export async function restartProjectOrchestrator({ + projectId, + queryClient, + navigate, + setProjectRestarting, + setOrchestratorReplacementError, + onError, +}: RestartProjectOrchestratorOptions) { + setProjectRestarting(projectId, true); + setOrchestratorReplacementError(projectId, null); + try { + const sessionId = await spawnOrchestrator(projectId, true); + await refreshWorkspaceState(queryClient); + void navigate({ + to: "/projects/$projectId/sessions/$sessionId", + params: { projectId, sessionId }, + }); + } catch (error) { + await refreshWorkspaceState(queryClient); + setOrchestratorReplacementError(projectId, error instanceof Error ? error.message : "Could not replace orchestrator"); + onError?.(error); + } finally { + setProjectRestarting(projectId, false); + } +} diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index a6f300d71..8891662b2 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { type CSSProperties, useCallback, useEffect, useRef } from "react"; import { ShellTopbar } from "../components/ShellTopbar"; +import { OrchestratorReplacementDialog } from "../components/OrchestratorReplacementDialog"; import { Sidebar } from "../components/Sidebar"; import { SidebarProvider } from "../components/ui/sidebar"; import { TitlebarNav } from "../components/TitlebarNav"; @@ -13,6 +14,8 @@ import { refreshDaemonStatus } from "../lib/daemon-status"; import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry"; import { ShellProvider } from "../lib/shell-context"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { restartProjectOrchestrator } from "../lib/restart-orchestrator"; +import { captureOrchestratorReplacementFailure } from "../lib/orchestrator-replacement-telemetry"; import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store"; import type { WorkspaceSummary } from "../types/workspace"; import type { components } from "../../api/schema"; @@ -48,6 +51,10 @@ function ShellLayout() { const daemonStatus = useDaemonStatus(queryClient); const agentCatalogPortRef = useRef(undefined); const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore(); + const setProjectRestarting = useUiStore((state) => state.setProjectRestarting); + const orchestratorReplacementErrors = useUiStore((state) => state.orchestratorReplacementErrors); + const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError); + const replacementErrorProjectId = Object.keys(orchestratorReplacementErrors)[0] ?? null; const updateWorkspaces = useCallback( (updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => { @@ -99,6 +106,7 @@ function ShellLayout() { name: data.project.name, path: data.project.path, type: "main", + orchestratorAgent: input.orchestratorAgent as WorkspaceSummary["orchestratorAgent"], sessions: [], }; void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id }); @@ -146,6 +154,22 @@ function ShellLayout() { [updateWorkspaces], ); + const restartOrchestrator = useCallback( + async (projectId: string) => { + await restartProjectOrchestrator({ + projectId, + queryClient, + navigate, + setProjectRestarting, + setOrchestratorReplacementError, + onError: (error) => { + captureOrchestratorReplacementFailure(error, projectId); + }, + }); + }, + [navigate, queryClient, setOrchestratorReplacementError, setProjectRestarting], + ); + useEffect(() => { document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; @@ -229,6 +253,15 @@ function ShellLayout() { by window-drag even though DOM hit-testing looks correct. */} + { + if (!open && replacementErrorProjectId) setOrchestratorReplacementError(replacementErrorProjectId, null); + }} + onRetry={(projectId) => void restartOrchestrator(projectId)} + projectId={replacementErrorProjectId} + workspaces={workspaces} + />
); diff --git a/frontend/src/renderer/stores/ui-store.ts b/frontend/src/renderer/stores/ui-store.ts index 237405512..7d50c8179 100644 --- a/frontend/src/renderer/stores/ui-store.ts +++ b/frontend/src/renderer/stores/ui-store.ts @@ -13,11 +13,15 @@ type UiState = { isSidebarOpen: boolean; isInspectorOpen: boolean; theme: Theme; + restartingProjectIds: ReadonlySet; + orchestratorReplacementErrors: Record; setWorkbenchTab: (tab: WorkbenchTab) => void; setTheme: (theme: Theme) => void; toggleTheme: () => void; toggleSidebar: () => void; toggleInspector: () => void; + setProjectRestarting: (projectId: string, restarting: boolean) => void; + setOrchestratorReplacementError: (projectId: string, message: string | null) => void; }; const sidebarStorageKey = "ao.sidebar.open"; @@ -58,6 +62,8 @@ export const useUiStore = create((set) => ({ isSidebarOpen: initialSidebarOpen(), isInspectorOpen: initialInspectorOpen(), theme: initialTheme(), + restartingProjectIds: new Set(), + orchestratorReplacementErrors: {}, setWorkbenchTab: (workbenchTab) => set({ workbenchTab }), setTheme: (theme) => { getLocalStorage()?.setItem(themeStorageKey, theme); @@ -81,4 +87,24 @@ export const useUiStore = create((set) => ({ getLocalStorage()?.setItem(inspectorStorageKey, String(isInspectorOpen)); return { isInspectorOpen }; }), + setProjectRestarting: (projectId, restarting) => + set((state) => { + const restartingProjectIds = new Set(state.restartingProjectIds); + if (restarting) { + restartingProjectIds.add(projectId); + } else { + restartingProjectIds.delete(projectId); + } + return { restartingProjectIds }; + }), + setOrchestratorReplacementError: (projectId, message) => + set((state) => { + const orchestratorReplacementErrors = { ...state.orchestratorReplacementErrors }; + if (message) { + orchestratorReplacementErrors[projectId] = message; + } else { + delete orchestratorReplacementErrors[projectId]; + } + return { orchestratorReplacementErrors }; + }), })); diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts index 992db2685..1f9021da6 100644 --- a/frontend/src/renderer/types/workspace.test.ts +++ b/frontend/src/renderer/types/workspace.test.ts @@ -3,6 +3,8 @@ import { attentionZone, canonicalTrackerIssueId, findProjectOrchestrator, + newestActiveOrchestrator, + orchestratorHealth, sessionIsActive, sessionNeedsAttention, toAgentProvider, @@ -144,6 +146,50 @@ describe("findProjectOrchestrator", () => { const live = sessionWith({ id: "skills-5", kind: "orchestrator", status: "working" }); expect(findProjectOrchestrator([workspaceWith([live])], "other")).toBeUndefined(); }); + + it("selects the newest active orchestrator, not the first active one", () => { + const older = sessionWith({ + id: "skills-1", + kind: "orchestrator", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }); + const newer = sessionWith({ + id: "skills-2", + kind: "orchestrator", + status: "working", + createdAt: "2026-01-02T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + }); + expect(findProjectOrchestrator([workspaceWith([older, newer])], "skills")).toBe(newer); + }); + + it("uses updatedAt and id as newest orchestrator tie breakers", () => { + const oldUpdate = sessionWith({ + id: "skills-2", + kind: "orchestrator", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }); + const newUpdate = sessionWith({ + id: "skills-1", + kind: "orchestrator", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + }); + const sameTimesHigherID = sessionWith({ + id: "skills-3", + kind: "orchestrator", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + }); + expect(newestActiveOrchestrator([oldUpdate, newUpdate])).toBe(newUpdate); + expect(newestActiveOrchestrator([newUpdate, sameTimesHigherID])).toBe(sameTimesHigherID); + }); }); describe("sessionNeedsAttention", () => { @@ -164,6 +210,50 @@ describe("sessionNeedsAttention", () => { }); }); +describe("orchestratorHealth", () => { + it("reports restart_needed when the configured orchestrator agent differs from the newest active orchestrator", () => { + const older = sessionWith({ + id: "skills-1", + kind: "orchestrator", + provider: "codex", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }); + const newest = sessionWith({ + id: "skills-2", + kind: "orchestrator", + provider: "claude-code", + status: "working", + createdAt: "2026-01-02T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + }); + + expect( + orchestratorHealth({ + id: "skills", + name: "skills", + path: "/tmp/skills", + orchestratorAgent: "codex", + sessions: [older, newest], + }), + ).toEqual({ + state: "duplicates", + message: "Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.", + }); + + expect( + orchestratorHealth({ + id: "skills", + name: "skills", + path: "/tmp/skills", + orchestratorAgent: "codex", + sessions: [newest], + }).state, + ).toBe("restart_needed"); + }); +}); + describe("workerStatusPulses", () => { it("pulses only for working and needs_you", () => { expect(workerStatusPulses("working")).toBe(true); diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index d3e597fa4..9d0ad4587 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -241,14 +241,31 @@ export function findProjectOrchestrator( projectId: string, ): WorkspaceSession | undefined { const workspace = workspaces.find((w) => w.id === projectId); - if (!workspace) return undefined; - for (let i = workspace.sessions.length - 1; i >= 0; i -= 1) { - const session = workspace.sessions[i]; - if (isOrchestratorSession(session) && sessionIsActive(session)) { - return session; - } - } - return undefined; + return newestActiveOrchestrator(workspace?.sessions ?? []); +} + +export function newestActiveOrchestrator(sessions: WorkspaceSession[]): WorkspaceSession | undefined { + const active = sessions.filter((session) => isOrchestratorSession(session) && sessionIsActive(session)); + return active.reduce( + (newest, session) => (!newest || sessionNewer(session, newest) ? session : newest), + undefined, + ); +} + +function sessionNewer(a: WorkspaceSession, b: WorkspaceSession): boolean { + const aCreated = timestamp(a.createdAt); + const bCreated = timestamp(b.createdAt); + if (aCreated !== bCreated) return aCreated > bCreated; + const aUpdated = timestamp(a.updatedAt); + const bUpdated = timestamp(b.updatedAt); + if (aUpdated !== bUpdated) return aUpdated > bUpdated; + return a.id > b.id; +} + +function timestamp(value?: string): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; } export function workerSessions(sessions: WorkspaceSession[]): WorkspaceSession[] { @@ -340,6 +357,7 @@ export type WorkspaceSummary = { name: string; path: string; type?: "main" | "worktree"; + orchestratorAgent?: AgentProvider; accentColor?: string; diff?: { additions: number; @@ -348,6 +366,42 @@ export type WorkspaceSummary = { sessions: WorkspaceSession[]; }; +export function orchestratorNeedsRestart(workspace: WorkspaceSummary, orchestrator?: WorkspaceSession): boolean { + if (!orchestrator || !workspace.orchestratorAgent) return false; + return orchestrator.provider !== workspace.orchestratorAgent; +} + +export type OrchestratorHealth = + | { state: "ok" } + | { state: "restarting"; message: string } + | { state: "restart_needed"; message: string } + | { state: "missing"; message: string } + | { state: "duplicates"; message: string }; + +export function orchestratorHealth(workspace: WorkspaceSummary, restarting = false): OrchestratorHealth { + if (restarting) { + return { state: "restarting", message: "Restarting orchestrator. New tasks wait until the replacement is ready." }; + } + const active = workspace.sessions.filter((session) => isOrchestratorSession(session) && sessionIsActive(session)); + if (active.length > 1) { + return { + state: "duplicates", + message: "Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.", + }; + } + const orchestrator = newestActiveOrchestrator(workspace.sessions); + if (!orchestrator) { + return { state: "missing", message: "No orchestrator is running for this project." }; + } + if (orchestratorNeedsRestart(workspace, orchestrator)) { + return { + state: "restart_needed", + message: `Configured orchestrator agent is ${workspace.orchestratorAgent}; running agent is ${orchestrator.provider}.`, + }; + } + return { state: "ok" }; +} + export function toAgentProvider(provider?: string): AgentProvider { switch (provider) { case "claude-code": From 5d7ad6137d04e1e7ef89d88a69425612b1cc85e8 Mon Sep 17 00:00:00 2001 From: Khushi Diwan Date: Sat, 4 Jul 2026 18:29:04 +0530 Subject: [PATCH 12/15] Fix restored terminal reconnect (#2313) * fix: reconnect restored terminals - Reopen terminal mux after restored sessions become live - Cover restore with unchanged terminal handles * fix: limit restored terminal reconnects * chore: format with prettier [skip ci] --------- Co-authored-by: github-actions[bot] --- .../hooks/useTerminalSession.test.tsx | 66 +++++++++++++++++++ .../src/renderer/hooks/useTerminalSession.ts | 19 ++++++ 2 files changed, 85 insertions(+) diff --git a/frontend/src/renderer/hooks/useTerminalSession.test.tsx b/frontend/src/renderer/hooks/useTerminalSession.test.tsx index dae10728d..aa4238762 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.test.tsx +++ b/frontend/src/renderer/hooks/useTerminalSession.test.tsx @@ -263,6 +263,72 @@ describe("useTerminalSession", () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey }); }); + it("reconnects when a restored session becomes live with the same terminal handle", () => { + const muxes: FakeMux[] = []; + const createMux = () => { + const fake = createFakeMux(); + muxes.push(fake); + return fake.mux; + }; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const view = renderHook( + ({ attachedSession }) => useTerminalSession(attachedSession, { daemonReady: true, createMux }), + { initialProps: { attachedSession: session }, wrapper }, + ); + const terminal = createFakeTerminal(); + act(() => { + view.result.current.attach(terminal); + }); + act(() => muxes[0].emitOpened("handle-1")); + act(() => muxes[0].emitExit("handle-1")); + expect(view.result.current.state).toBe("exited"); + + view.rerender({ attachedSession: { ...session, status: "terminated", updatedAt: "terminated" } }); + expect(muxes).toHaveLength(1); + + view.rerender({ attachedSession: { ...session, status: "idle", updatedAt: "restored" } }); + expect(view.result.current.state).toBe("connecting"); + expect(muxes).toHaveLength(2); + expect(muxes[0].disposed).toBe(true); + expect(muxes[1].opens).toEqual([["handle-1", 80, 24]]); + act(() => muxes[1].emitOpened("handle-1")); + expect(view.result.current.state).toBe("attached"); + terminal.typeKeys("echo ok\r"); + expect(muxes[1].inputs).toEqual([["handle-1", "echo ok\r"]]); + }); + + it("does not reconnect a broken live pane on ordinary session updates", () => { + const muxes: FakeMux[] = []; + const createMux = () => { + const fake = createFakeMux(); + muxes.push(fake); + return fake.mux; + }; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const view = renderHook( + ({ attachedSession }) => useTerminalSession(attachedSession, { daemonReady: true, createMux }), + { initialProps: { attachedSession: session }, wrapper }, + ); + const terminal = createFakeTerminal(); + act(() => { + view.result.current.attach(terminal); + }); + act(() => muxes[0].emitError("handle-1", "no such pane")); + expect(view.result.current.state).toBe("error"); + + view.rerender({ attachedSession: { ...session, status: "idle", updatedAt: "tick-1" } }); + view.rerender({ attachedSession: { ...session, status: "working", updatedAt: "tick-2" } }); + + expect(view.result.current.state).toBe("error"); + expect(muxes).toHaveLength(1); + }); + it("surfaces pane errors and refetches, with no automatic retry", () => { const { view, muxes, invalidateSpy } = setup(); act(() => muxes[0].emitError("handle-1", "no such pane")); diff --git a/frontend/src/renderer/hooks/useTerminalSession.ts b/frontend/src/renderer/hooks/useTerminalSession.ts index ae36c03d3..70365e350 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.ts +++ b/frontend/src/renderer/hooks/useTerminalSession.ts @@ -80,6 +80,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option const sessionRef = useRef(session); sessionRef.current = session; + const previousSessionStatusRef = useRef(session?.status); const optionsRef = useRef(options); optionsRef.current = options; const stateRef = useRef(state); @@ -323,6 +324,24 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option connect(); }, [daemonReady, connect]); + useEffect(() => { + const r = runtime.current; + const handle = session?.terminalHandleId ?? null; + const previousStatus = previousSessionStatusRef.current; + previousSessionStatusRef.current = session?.status; + if (!handle || previousStatus !== "terminated" || session?.status === "terminated" || r.detached || !r.terminal) { + return; + } + if (r.handle !== handle) return; + if (stateRef.current !== "exited" && stateRef.current !== "error") return; + if (optionsRef.current.daemonReady) { + transition("connecting"); + connect(); + } else { + transition("reattaching"); + } + }, [connect, session?.status, session?.terminalHandleId, transition]); + // Belt-and-braces: never leak a socket past unmount, even if the owner // forgot to call detach. useEffect( From 2c08597ee6047e3b8045f096fd70a8af24f8728f Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Sat, 4 Jul 2026 20:49:15 +0530 Subject: [PATCH 13/15] refactor(adapters): cut ~3,100 LOC of redundancy in the agent adapter layer (#2349) (#2355) * refactor(adapters): add shared helpers for adapter dedup (#2349) Introduce the shared building blocks the per-adapter cleanup will use: - ports.NormalizePermissionMode (finding 3) - hookutil.FileExists (finding 10) - binaryutil.ResolveBinary + BinarySpec (finding 2) - activitystate.StandardDeriveActivityState (finding 4) - agentbase.Base embed + StandardSessionInfo (findings 6-9) No adapters wired up yet; behavior unchanged. Co-Authored-By: Claude Opus 4.8 * refactor(goose): convert to shared adapter helpers (reference) (#2349) Reference conversion proving the shared packages against real tests: - hooks.go collapses onto hooksjson.Manager (finding 1) - ResolveGooseBinary via binaryutil.BinarySpec (finding 2) - gooseMode uses ports.NormalizePermissionMode (finding 3) - activity.go deleted; dispatch points at activitystate (findings 4, 5) - SessionInfo via agentbase.StandardSessionInfo; Base embed drops the GetConfigSpec/GetPromptDeliveryStrategy no-ops (findings 6-8) - fileExists/atomicWriteFile copies removed (findings 5, 10) Also adds hooksjson package + activitystate test. goose: 327->~90 LOC. Co-Authored-By: Claude Opus 4.8 * refactor(adapters): dedup remaining 22 agent adapters onto shared helpers (#2349) Applies the shared helpers across every remaining adapter: - hooksjson.Manager for the matcher-group cohort (claudecode, qwen, droid) - binaryutil.BinarySpec for 19 binary resolvers (aider/cursor/opencode/codex keep their special resolvers; all now use hookutil.FileExists) - ports.NormalizePermissionMode replaces 15 private copies - agentbase.Base embed drops the GetConfigSpec/GetPromptDeliveryStrategy no-ops (and GetAgentHooks/GetRestoreCommand/SessionInfo no-ops on hookless adapters) - agentbase.StandardSessionInfo replaces the per-adapter metadata readers - activitystate.StandardDeriveActivityState via dispatch for the 8 name-only derivers; their activity.go files removed (claudecode/codex/droid/agy/opencode keep payload-parsing derivers) - 4 private atomicWriteFile copies missing fsync now use hookutil.AtomicWriteFile - 23 private fileExists copies removed Full backend build + vet + test suite green (1647 tests). Co-Authored-By: Claude Opus 4.8 * chore: format with prettier [skip ci] * fix(binaryutil): preserve per-adapter Windows candidate order (#2349) Review feedback: the shared resolver hardcoded Windows candidate order as APPDATA then LOCALAPPDATA, which flipped Kiro's lookup so the npm shim (%APPDATA%\npm\kiro-cli.*) was probed before the native install (%LOCALAPPDATA%\Programs\kiro\kiro-cli.exe). A fixed order can't preserve every adapter's original order (goose/vibe want APPDATA first, kiro wants LOCALAPPDATA first), so BinarySpec now takes an ordered WinPaths []WinPath list where each entry names its base (WinAppData/WinLocalAppData/WinHome). Every adapter's list reproduces its pre-refactor order exactly; kiro's native path is restored to first. go build / vet / test all green (1647 tests). Co-Authored-By: Claude Opus 4.8 * test(adapters): drop unused resolvedBinary writes flagged by govet (#2349) Embedding agentbase.Base (value receiver) lets govet's unusedwrite analyzer prove that setting resolvedBinary in tests that only call Base-promoted methods (GetConfigSpec/GetPromptDeliveryStrategy/SessionInfo/cancellation) is a dead write. Drop the field from those 23 constructions; GetLaunchCommand/GetRestore tests that actually read it keep it. Co-Authored-By: Claude Opus 4.8 * style: gofmt manager_test.go struct alignment (#2349) Inherited via the merge of main: a SessionRecord literal aligned its Metadata field across a multi-key line, which gofmt/goimports rejects. One-line reformat to unblock CI. Co-Authored-By: Claude Opus 4.8 * docs(adapters): fix stale resolver fallback comments (#2349) Review nit: 6 Resolve*Binary functions now delegate to binaryutil.ResolveBinary, which returns a wrapped ports.ErrAgentBinaryNotFound rather than the bare binary name. Update their doc comments (kiro, vibe, amp, agy, crush, cline) to match. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: github-actions[bot] --- .../agent/activitydispatch/dispatch.go | 29 +- .../agent/activitystate/activitystate.go | 32 ++ .../agent/activitystate/activitystate_test.go | 28 ++ .../adapters/agent/agentbase/agentbase.go | 69 ++++ backend/internal/adapters/agent/agy/agy.go | 129 +------ .../internal/adapters/agent/agy/agy_test.go | 2 +- .../internal/adapters/agent/aider/aider.go | 52 +-- .../adapters/agent/aider/aider_test.go | 2 +- backend/internal/adapters/agent/amp/amp.go | 116 +----- .../internal/adapters/agent/auggie/auggie.go | 118 +----- .../adapters/agent/autohand/activity.go | 26 -- .../adapters/agent/autohand/autohand.go | 148 ++----- .../adapters/agent/autohand/autohand_test.go | 13 +- .../internal/adapters/agent/autohand/hooks.go | 26 +- .../adapters/agent/binaryutil/binaryutil.go | 134 +++++++ .../agent/binaryutil/binaryutil_test.go | 81 ++++ .../adapters/agent/claudecode/claudecode.go | 111 +----- .../agent/claudecode/claudecode_test.go | 13 +- .../adapters/agent/claudecode/hooks.go | 334 ++-------------- .../internal/adapters/agent/cline/activity.go | 32 -- .../internal/adapters/agent/cline/cline.go | 138 +------ .../adapters/agent/cline/cline_test.go | 37 +- .../internal/adapters/agent/cline/hooks.go | 31 +- .../internal/adapters/agent/codex/codex.go | 45 +-- .../adapters/agent/codex/codex_test.go | 4 +- .../agent/continueagent/continueagent.go | 124 +----- .../adapters/agent/copilot/activity.go | 38 -- .../adapters/agent/copilot/copilot.go | 72 +--- .../adapters/agent/copilot/copilot_test.go | 32 +- .../internal/adapters/agent/copilot/hooks.go | 30 +- .../internal/adapters/agent/crush/crush.go | 117 +----- .../adapters/agent/crush/crush_test.go | 2 +- .../adapters/agent/cursor/activity.go | 30 -- .../adapters/agent/cursor/activity_test.go | 32 -- .../internal/adapters/agent/cursor/cursor.go | 57 +-- .../adapters/agent/cursor/cursor_test.go | 8 +- .../internal/adapters/agent/devin/devin.go | 124 +----- .../adapters/agent/devin/devin_test.go | 4 +- .../internal/adapters/agent/droid/droid.go | 144 ++----- .../adapters/agent/droid/droid_test.go | 4 +- .../internal/adapters/agent/droid/hooks.go | 334 +--------------- .../internal/adapters/agent/goose/activity.go | 35 -- .../adapters/agent/goose/activity_test.go | 32 -- .../internal/adapters/agent/goose/goose.go | 142 ++----- .../adapters/agent/goose/goose_test.go | 17 +- .../internal/adapters/agent/goose/hooks.go | 339 ++--------------- backend/internal/adapters/agent/grok/grok.go | 130 +------ .../adapters/agent/hooksjson/hooksjson.go | 332 ++++++++++++++++ .../adapters/agent/hookutil/hookutil.go | 8 + .../adapters/agent/kilocode/activity.go | 31 -- .../internal/adapters/agent/kilocode/hooks.go | 30 +- .../adapters/agent/kilocode/kilocode.go | 145 ++----- .../adapters/agent/kilocode/kilocode_test.go | 13 +- backend/internal/adapters/agent/kimi/kimi.go | 126 +----- .../internal/adapters/agent/kiro/activity.go | 31 -- backend/internal/adapters/agent/kiro/hooks.go | 25 +- backend/internal/adapters/agent/kiro/kiro.go | 153 ++------ .../internal/adapters/agent/kiro/kiro_test.go | 32 +- .../adapters/agent/opencode/opencode.go | 67 +--- .../adapters/agent/opencode/opencode_test.go | 8 +- backend/internal/adapters/agent/pi/pi.go | 114 +----- .../internal/adapters/agent/qwen/activity.go | 31 -- backend/internal/adapters/agent/qwen/hooks.go | 360 +----------------- backend/internal/adapters/agent/qwen/qwen.go | 140 +------ .../internal/adapters/agent/qwen/qwen_test.go | 41 +- backend/internal/adapters/agent/vibe/vibe.go | 123 +----- backend/internal/ports/agent.go | 16 + 67 files changed, 1300 insertions(+), 4123 deletions(-) create mode 100644 backend/internal/adapters/agent/activitystate/activitystate.go create mode 100644 backend/internal/adapters/agent/activitystate/activitystate_test.go create mode 100644 backend/internal/adapters/agent/agentbase/agentbase.go delete mode 100644 backend/internal/adapters/agent/autohand/activity.go create mode 100644 backend/internal/adapters/agent/binaryutil/binaryutil.go create mode 100644 backend/internal/adapters/agent/binaryutil/binaryutil_test.go delete mode 100644 backend/internal/adapters/agent/cline/activity.go delete mode 100644 backend/internal/adapters/agent/copilot/activity.go delete mode 100644 backend/internal/adapters/agent/cursor/activity.go delete mode 100644 backend/internal/adapters/agent/cursor/activity_test.go delete mode 100644 backend/internal/adapters/agent/goose/activity.go delete mode 100644 backend/internal/adapters/agent/goose/activity_test.go create mode 100644 backend/internal/adapters/agent/hooksjson/hooksjson.go delete mode 100644 backend/internal/adapters/agent/kilocode/activity.go delete mode 100644 backend/internal/adapters/agent/kiro/activity.go delete mode 100644 backend/internal/adapters/agent/qwen/activity.go diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index 812e15e29..9e849e0bd 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -9,19 +9,12 @@ package activitydispatch import ( + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agy" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/autohand" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cline" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/goose" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kilocode" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kiro" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/qwen" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) @@ -32,19 +25,21 @@ type DeriveFunc func(event string, payload []byte) (domain.ActivityState, bool) // Derivers maps the agent token in `ao hooks ` to its deriver. // Per-adapter PRs add their tokens here as they land. var Derivers = map[string]DeriveFunc{ + // Adapters that parse hook payloads for finer-grained state keep their own + // deriver; the rest share the name-only StandardDeriveActivityState. "claude-code": claudecode.DeriveActivityState, "codex": codex.DeriveActivityState, - "cursor": cursor.DeriveActivityState, - "opencode": opencode.DeriveActivityState, - "qwen": qwen.DeriveActivityState, - "copilot": copilot.DeriveActivityState, "droid": droid.DeriveActivityState, "agy": agy.DeriveActivityState, - "goose": goose.DeriveActivityState, - "cline": cline.DeriveActivityState, - "kiro": kiro.DeriveActivityState, - "kilocode": kilocode.DeriveActivityState, - "autohand": autohand.DeriveActivityState, + "opencode": opencode.DeriveActivityState, + "goose": activitystate.StandardDeriveActivityState, + "cursor": activitystate.StandardDeriveActivityState, + "qwen": activitystate.StandardDeriveActivityState, + "copilot": activitystate.StandardDeriveActivityState, + "cline": activitystate.StandardDeriveActivityState, + "kiro": activitystate.StandardDeriveActivityState, + "kilocode": activitystate.StandardDeriveActivityState, + "autohand": activitystate.StandardDeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when diff --git a/backend/internal/adapters/agent/activitystate/activitystate.go b/backend/internal/adapters/agent/activitystate/activitystate.go new file mode 100644 index 000000000..3c5375d9a --- /dev/null +++ b/backend/internal/adapters/agent/activitystate/activitystate.go @@ -0,0 +1,32 @@ +// Package activitystate holds the standard mapping from an AO hook sub-command +// name onto an activity state. Most adapters install the same +// session-start/user-prompt-submit/stop/permission-request callbacks and derive +// activity identically from the event name alone; they share this deriver rather +// than each carrying a copy. Adapters that inspect the hook payload for finer +// grained state (claude-code, codex, droid) keep their own deriver. +package activitystate + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// StandardDeriveActivityState maps a hook sub-command name onto an AO activity +// state. The bool is false when the event carries no activity signal. The +// payload is ignored: this is the name-only mapping shared by adapters whose +// hooks report activity purely through which callback fired. +// +// - session-start / user-prompt-submit → active +// - stop → idle +// - permission-request → waiting_input +func StandardDeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/activitystate/activitystate_test.go b/backend/internal/adapters/agent/activitystate/activitystate_test.go new file mode 100644 index 000000000..3fe8f91b9 --- /dev/null +++ b/backend/internal/adapters/agent/activitystate/activitystate_test.go @@ -0,0 +1,28 @@ +package activitystate + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestStandardDeriveActivityState(t *testing.T) { + cases := []struct { + event string + want domain.ActivityState + wantOK bool + }{ + {"session-start", domain.ActivityActive, true}, + {"user-prompt-submit", domain.ActivityActive, true}, + {"stop", domain.ActivityIdle, true}, + {"permission-request", domain.ActivityWaitingInput, true}, + {"unknown", "", false}, + {"", "", false}, + } + for _, tc := range cases { + got, ok := StandardDeriveActivityState(tc.event, []byte("ignored")) + if got != tc.want || ok != tc.wantOK { + t.Errorf("event %q: got (%q, %v), want (%q, %v)", tc.event, got, ok, tc.want, tc.wantOK) + } + } +} diff --git a/backend/internal/adapters/agent/agentbase/agentbase.go b/backend/internal/adapters/agent/agentbase/agentbase.go new file mode 100644 index 000000000..7c1a302a7 --- /dev/null +++ b/backend/internal/adapters/agent/agentbase/agentbase.go @@ -0,0 +1,69 @@ +// Package agentbase supplies the defaults an agent adapter would otherwise +// hand-copy. Most adapters implement several ports.Agent methods identically: +// no config keys, prompt delivered in the launch command, and (for the simpler +// harnesses) no hooks, no resume, no session metadata. Embedding Base gives an +// adapter those defaults so it only writes the methods it actually customizes. +package agentbase + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Base provides no-op defaults for the optional ports.Agent methods. Embed it in +// a Plugin struct (`agentbase.Base`) and override only what the harness needs. +// Every method honors ctx cancellation and otherwise does nothing, matching what +// the adapters previously wrote by hand. +type Base struct{} + +// GetConfigSpec reports no agent-specific config keys. +func (Base) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, ctx.Err() +} + +// GetPromptDeliveryStrategy reports that the agent receives its prompt in the +// launch command itself, which is true for every shipped adapter. +func (Base) GetPromptDeliveryStrategy(ctx context.Context, _ ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks is a no-op for harnesses without a native hook surface. +func (Base) GetAgentHooks(ctx context.Context, _ ports.WorkspaceHookConfig) error { + return ctx.Err() +} + +// GetRestoreCommand reports that no existing native session can be continued. +func (Base) GetRestoreCommand(ctx context.Context, _ ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + return nil, false, nil +} + +// SessionInfo reports no agent-owned session metadata. +func (Base) SessionInfo(ctx context.Context, _ ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + return ports.SessionInfo{}, false, nil +} + +// StandardSessionInfo returns the normalized session metadata (native session +// id, title, summary) an adapter's hooks persisted under the shared +// ports.MetadataKey* keys. ok is false when none of the three is present. An +// adapter whose SessionInfo just reads those keys delegates here. +func StandardSessionInfo(session ports.SessionRef) (ports.SessionInfo, bool) { + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[ports.MetadataKeyTitle], + Summary: session.Metadata[ports.MetadataKeySummary], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false + } + return info, true +} diff --git a/backend/internal/adapters/agent/agy/agy.go b/backend/internal/adapters/agent/agy/agy.go index e29563bad..c527cc125 100644 --- a/backend/internal/adapters/agent/agy/agy.go +++ b/backend/internal/adapters/agent/agy/agy.go @@ -5,30 +5,34 @@ package agy import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - adapterID = "agy" +const adapterID = "agy" - // Normalized session-metadata keys. Shared vocabulary with the Codex and Claude Code - // adapters so the dashboard treats every agent uniformly. - agyTitleMetadataKey = "title" - agySummaryMetadataKey = "summary" -) +var agyBinarySpec = binaryutil.BinarySpec{ + Label: "agy", + Names: []string{"agy"}, + WinNames: []string{"agy.cmd", "agy.exe", "agy"}, + UnixPaths: []string{"/usr/local/bin/agy", "/opt/homebrew/bin/agy"}, + UnixHomePaths: [][]string{{".local", "bin", "agy"}, {".cargo", "bin", "agy"}, {".npm", "bin", "agy"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "agy.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "agy.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "agy.exe"}}, + }, +} // Plugin is the Agy agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.RWMutex resolvedBinary string } @@ -54,14 +58,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Agy exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start an interactive Agy session. // Shape: // @@ -89,15 +85,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Agy receives its prompt in the -// launch command itself via --prompt-interactive. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Agy session: // `agy --add-dir [--dangerously-skip-permissions] --conversation `. func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { @@ -134,84 +121,15 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[agyTitleMetadataKey], - Summary: session.Metadata[agySummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveAgyBinary returns the path to the agy binary on this machine, -// searching PATH then a handful of well-known install locations. -// Returns "agy" as a last-ditch fallback. +// searching PATH then a handful of well-known install locations. It returns a +// wrapped ports.ErrAgentBinaryNotFound when agy is absent. func ResolveAgyBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"agy.cmd", "agy.exe", "agy"} { - path, err := exec.LookPath(name) - if err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "agy.cmd"), - filepath.Join(appData, "npm", "agy.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "agy.exe")) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("agy: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("agy"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/agy", - "/opt/homebrew/bin/agy", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "agy"), - filepath.Join(home, ".cargo", "bin", "agy"), - filepath.Join(home, ".npm", "bin", "agy"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("agy: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, agyBinarySpec) } func (p *Plugin) agyBinary(ctx context.Context) (string, error) { @@ -238,8 +156,3 @@ func (p *Plugin) agyBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/agy/agy_test.go b/backend/internal/adapters/agent/agy/agy_test.go index db6202c2e..eba2c0d29 100644 --- a/backend/internal/adapters/agent/agy/agy_test.go +++ b/backend/internal/adapters/agent/agy/agy_test.go @@ -43,7 +43,7 @@ func TestGetLaunchCommand(t *testing.T) { } func TestGetPromptDeliveryStrategy(t *testing.T) { - plugin := &Plugin{resolvedBinary: "agy"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { t.Fatal(err) diff --git a/backend/internal/adapters/agent/aider/aider.go b/backend/internal/adapters/agent/aider/aider.go index e813b88a2..148885d44 100644 --- a/backend/internal/adapters/agent/aider/aider.go +++ b/backend/internal/adapters/agent/aider/aider.go @@ -18,6 +18,8 @@ import ( "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -26,6 +28,7 @@ const adapterID = "aider" // Plugin is the Aider agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -51,14 +54,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a headless Aider session: // // aider -m [permission flags] --no-check-update --no-stream --no-pretty [--read ] @@ -91,40 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Aider receives its prompt in the launch -// command itself (via -m). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is a no-op: Aider emits no lifecycle hooks (Tier C), so there -// is no native hook config to install AO hooks into. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - -// GetRestoreCommand always reports that no native session can be continued. -// Aider has no native session id or resume-by-id mechanism -// (see github.com/Aider-AI/aider issues/166), so the manager always falls back -// to a fresh launch. -func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { - if err := ctx.Err(); err != nil { - return nil, false, err - } - return nil, false, nil -} - -// SessionInfo is a no-op: Aider exposes no captureable session metadata. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil -} - // normalizePermissionMode collapses an empty mode onto PermissionModeDefault so // callers can switch over a stable set of values. func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { @@ -190,7 +151,7 @@ func ResolveAiderBinary(ctx context.Context) (string, error) { } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -216,8 +177,3 @@ func (p *Plugin) aiderBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/aider/aider_test.go b/backend/internal/adapters/agent/aider/aider_test.go index 72bcaffdf..9a32707e2 100644 --- a/backend/internal/adapters/agent/aider/aider_test.go +++ b/backend/internal/adapters/agent/aider/aider_test.go @@ -217,7 +217,7 @@ func TestGetLaunchCommandInlineSystemPromptIsDropped(t *testing.T) { } func TestGetRestoreCommandAlwaysFalse(t *testing.T) { - p := &Plugin{resolvedBinary: "aider"} + p := &Plugin{} cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ Session: ports.SessionRef{ Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abc123"}, diff --git a/backend/internal/adapters/agent/amp/amp.go b/backend/internal/adapters/agent/amp/amp.go index 65c3512a8..4f0580744 100644 --- a/backend/internal/adapters/agent/amp/amp.go +++ b/backend/internal/adapters/agent/amp/amp.go @@ -8,15 +8,12 @@ package amp import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -25,6 +22,7 @@ const adapterID = "amp" // Plugin is the Amp agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -50,14 +48,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new interactive Amp session: // // amp [--permission-mode ] [--append-system-prompt ] [-- ] @@ -87,21 +77,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Amp receives its prompt in the launch -// command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is intentionally a no-op until Amp activity can be reported via -// an Amp-specific plugin. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - // GetRestoreCommand rebuilds the argv that continues an existing Amp session // when plugin-derived native session metadata is available. Until that metadata // exists, ok is false and callers fall back to fresh launch behavior. @@ -126,14 +101,6 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo is intentionally a no-op until Amp plugin metadata exists. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil -} - func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) { switch mode { case ports.PermissionModeAcceptEdits: @@ -145,66 +112,22 @@ func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) { } } +var ampBinarySpec = binaryutil.BinarySpec{ + Label: "amp", + Names: []string{"amp"}, + WinNames: []string{"amp.cmd", "amp.exe", "amp"}, + UnixPaths: []string{"/usr/local/bin/amp", "/opt/homebrew/bin/amp"}, + UnixHomePaths: [][]string{{".local", "bin", "amp"}, {".npm", "bin", "amp"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.exe"}}, + }, +} + // ResolveAmpBinary finds the `amp` binary, searching PATH then common install -// locations. It returns "amp" as a last resort so callers get the shell's normal -// command-not-found behavior if Amp is absent. +// locations. It returns a wrapped ports.ErrAgentBinaryNotFound when Amp is absent. func ResolveAmpBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"amp.cmd", "amp.exe", "amp"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "amp.cmd"), - filepath.Join(appData, "npm", "amp.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("amp"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/amp", - "/opt/homebrew/bin/amp", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "amp"), - filepath.Join(home, ".npm", "bin", "amp"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, ampBinarySpec) } func (p *Plugin) ampBinary(ctx context.Context) (string, error) { @@ -222,8 +145,3 @@ func (p *Plugin) ampBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/auggie/auggie.go b/backend/internal/adapters/agent/auggie/auggie.go index e74dc78d5..71ddf5a17 100644 --- a/backend/internal/adapters/agent/auggie/auggie.go +++ b/backend/internal/adapters/agent/auggie/auggie.go @@ -4,7 +4,7 @@ // // Auggie is Augment Code's terminal coding agent (binary "auggie", installed via // `npm install -g @augmentcode/auggie`). It exposes a headless one-shot mode via -// `--print` (alias `-p`) which runs a single instruction and exits — the mode AO +// `--print` (alias `-p`) which runs a single instruction and exits -- the mode AO // uses to drive it unattended. // // Launch shape: @@ -38,15 +38,12 @@ package auggie import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -55,6 +52,7 @@ const adapterID = "auggie" // Plugin is the Auggie agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -80,14 +78,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new headless Auggie session: // // auggie --print [--instruction-file | --instruction ] [-- ] @@ -116,22 +106,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Auggie receives its prompt in the launch -// command itself (the print-mode positional). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is intentionally a no-op: Auggie has no hook or lifecycle event -// system, so there is nothing to install. Activity reporting will require an -// Auggie-specific integration once one exists. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - // GetRestoreCommand rebuilds the argv that continues an existing Auggie session // when a native session id is available in metadata: // @@ -156,81 +130,28 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo is intentionally a no-op until Auggie session metadata can be -// captured (Auggie exposes no hook surface to derive it from). -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil -} - // Auggie has no single blanket auto-approve/bypass flag; unattended tool/file // approval is governed by granular `--permission :` rules, so // AO emits no approval flag and defers every mode to the user's Auggie config. // There is therefore no appendApprovalFlags helper for this adapter. +var auggieBinarySpec = binaryutil.BinarySpec{ + Label: "auggie", + Names: []string{"auggie"}, + WinNames: []string{"auggie.cmd", "auggie.exe", "auggie"}, + UnixPaths: []string{"/usr/local/bin/auggie", "/opt/homebrew/bin/auggie"}, + UnixHomePaths: [][]string{{".local", "bin", "auggie"}, {".npm", "bin", "auggie"}, {".npm-global", "bin", "auggie"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "auggie.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "auggie.exe"}}, + }, +} + // ResolveAuggieBinary finds the `auggie` binary, searching PATH then common // install locations. It returns "auggie" as a last resort so callers get the // shell's normal command-not-found behavior if Auggie is absent. func ResolveAuggieBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"auggie.cmd", "auggie.exe", "auggie"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "auggie.cmd"), - filepath.Join(appData, "npm", "auggie.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("auggie: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("auggie"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/auggie", - "/opt/homebrew/bin/auggie", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "auggie"), - filepath.Join(home, ".npm", "bin", "auggie"), - filepath.Join(home, ".npm-global", "bin", "auggie"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("auggie: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, auggieBinarySpec) } func (p *Plugin) auggieBinary(ctx context.Context) (string, error) { @@ -248,8 +169,3 @@ func (p *Plugin) auggieBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/autohand/activity.go b/backend/internal/adapters/agent/autohand/activity.go deleted file mode 100644 index e1280f371..000000000 --- a/backend/internal/adapters/agent/autohand/activity.go +++ /dev/null @@ -1,26 +0,0 @@ -package autohand - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps an Autohand hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in autohandManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), routed -// from Autohand's native lifecycle events. Autohand has no SessionEnd/process- -// exit hook wired into the adapter, so runtime exit still falls back to the -// lifecycle reaper. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/autohand/autohand.go b/backend/internal/adapters/agent/autohand/autohand.go index 988bc9ac2..90daf1f22 100644 --- a/backend/internal/adapters/agent/autohand/autohand.go +++ b/backend/internal/adapters/agent/autohand/autohand.go @@ -6,34 +6,27 @@ // command mode (`autohand -p ` / positional prompt), native session // resume (`autohand resume `), and a native hook/lifecycle system // whose events (session-start, stop, permission-request, ...) AO maps onto -// activity states. See hooks.go for hook installation and activity.go for the -// event→state mapping. +// activity states. See hooks.go for hook installation. package autohand import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - adapterID = "autohand" - - autohandTitleMetadataKey = "title" - autohandSummaryMetadataKey = "summary" -) +const adapterID = "autohand" // Plugin is the Autohand agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base + binaryMu sync.Mutex resolvedBinary string } @@ -59,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Autohand exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new Autohand command-mode session, // scoping the run to the workspace, applying the approval-mode flags and optional // system-prompt override, and passing the initial prompt as a positional argument @@ -98,15 +83,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Autohand receives its prompt in the -// launch command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Autohand // session: `autohand resume [--path ] `. ok is false when // the hook-derived native session id has not landed yet, so callers can fall @@ -139,15 +115,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[autohandTitleMetadataKey], - Summary: session.Metadata[autohandSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // appendWorkspaceFlag scopes the run to the given workspace path via --path. @@ -160,10 +129,10 @@ func appendWorkspaceFlag(cmd *[]string, workspacePath string) { // appendApprovalFlags maps AO's four permission modes onto Autohand's approval // flags. Default emits no flag so Autohand resolves its starting mode from the // user's own config (permissions.mode). Autohand has no distinct "accept-edits" -// mode, so it maps to --yes (auto-confirm risky actions) — the least-privileged -// non-interactive option — while auto/bypass map to --unrestricted. +// mode, so it maps to --yes (auto-confirm risky actions) -- the least-privileged +// non-interactive option -- while auto/bypass map to --unrestricted. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Autohand config/default behavior. case ports.PermissionModeAcceptEdits: @@ -175,85 +144,23 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } +var autohandBinarySpec = binaryutil.BinarySpec{ + Label: "autohand", + Names: []string{"autohand"}, + WinNames: []string{"autohand.cmd", "autohand.exe", "autohand"}, + UnixPaths: []string{"/usr/local/bin/autohand", "/opt/homebrew/bin/autohand"}, + UnixHomePaths: [][]string{{".local", "bin", "autohand"}, {".npm", "bin", "autohand"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".local", "bin", "autohand.exe"}}, + }, } -// ResolveAutohandBinary returns the path to the autohand binary on this machine, -// searching PATH then a handful of well-known install locations (Homebrew, the -// official ~/.local/bin installer, npm global). Returns "autohand" as a -// last-ditch fallback so callers see a clear "command not found" rather than an -// empty argv. +// ResolveAutohandBinary returns the path to the autohand binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveAutohandBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"autohand.cmd", "autohand.exe", "autohand"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "autohand.cmd"), - filepath.Join(appData, "npm", "autohand.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, ".local", "bin", "autohand.exe")) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("autohand"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/autohand", - "/opt/homebrew/bin/autohand", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "autohand"), - filepath.Join(home, ".npm", "bin", "autohand"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, autohandBinarySpec) } func (p *Plugin) autohandBinary(ctx context.Context) (string, error) { @@ -277,8 +184,3 @@ func (p *Plugin) autohandBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/autohand/autohand_test.go b/backend/internal/adapters/agent/autohand/autohand_test.go index 9e96bbfff..9387bd3e8 100644 --- a/backend/internal/adapters/agent/autohand/autohand_test.go +++ b/backend/internal/adapters/agent/autohand/autohand_test.go @@ -8,6 +8,7 @@ import ( "reflect" "testing" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -125,7 +126,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "autohand"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -137,7 +138,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "autohand"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -208,8 +209,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "sess-123", - autohandTitleMetadataKey: "Fix login redirect", - autohandSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -356,9 +357,9 @@ func TestDeriveActivityState(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, []byte(`{}`)) + got, ok := activitystate.StandardDeriveActivityState(tt.event, []byte(`{}`)) if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", + t.Fatalf("StandardDeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, got, ok, tt.want, tt.wantOK) } }) diff --git a/backend/internal/adapters/agent/autohand/hooks.go b/backend/internal/adapters/agent/autohand/hooks.go index 084515bba..1e579d423 100644 --- a/backend/internal/adapters/agent/autohand/hooks.go +++ b/backend/internal/adapters/agent/autohand/hooks.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -294,35 +295,12 @@ func writeAutohandHooks(configPath string, topLevel, hooksSection map[string]jso return fmt.Errorf("encode %s: %w", configPath, err) } data = append(data, '\n') - if err := atomicWriteFile(configPath, data, 0o600); err != nil { + if err := hookutil.AtomicWriteFile(configPath, data, 0o600); err != nil { return fmt.Errorf("write %s: %w", configPath, err) } return nil } -// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- -// write can't leave a truncated/empty config that Autohand then fails to parse. -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} - func isAutohandManagedHook(command string) bool { return strings.HasPrefix(command, autohandHookCommandPrefix) } diff --git a/backend/internal/adapters/agent/binaryutil/binaryutil.go b/backend/internal/adapters/agent/binaryutil/binaryutil.go new file mode 100644 index 000000000..8016056e5 --- /dev/null +++ b/backend/internal/adapters/agent/binaryutil/binaryutil.go @@ -0,0 +1,134 @@ +// Package binaryutil centralizes the "find an agent's CLI binary" search that +// every adapter otherwise reimplements. Adapters differ only in the binary +// name(s) and the well-known install locations to probe, so they describe those +// with a BinarySpec and share the identical PATH-then-candidates iteration. +package binaryutil + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// BinarySpec describes where one agent's CLI binary can live. ResolveBinary +// searches PATH (via the platform's name list) first, then the platform's +// candidate install paths in order, returning the first hit. +// +// Path components are given as string slices joined onto their base directory, +// so a spec stays OS-agnostic and never hard-codes a separator. env-derived +// bases (APPDATA, LOCALAPPDATA, home) that are unset simply skip their +// candidates. +type BinarySpec struct { + // Label prefixes the ErrAgentBinaryNotFound error, e.g. "claude". + Label string + + // Names are the binary names looked up on PATH on non-Windows, in order. + Names []string + // WinNames are the binary names looked up on PATH on Windows, in order. + // Empty means the Windows branch does no PATH lookup. + WinNames []string + + // UnixPaths are absolute candidate paths probed on non-Windows, in order. + UnixPaths []string + // UnixHomePaths are candidate paths under the user's home dir on + // non-Windows; each entry is the components to join onto $HOME. + UnixHomePaths [][]string + + // WinPaths are candidate paths probed on Windows, in the exact order given. + // Each entry names the base directory (%APPDATA%, %LOCALAPPDATA%, or home) it + // is joined onto. Order is significant: a native installer location listed + // before an npm shim wins when both are present, so it is spelled out here + // rather than assumed. Entries whose base env is unset are skipped. + WinPaths []WinPath +} + +// WinBase names the base directory a Windows candidate path is joined onto. +type WinBase int + +// The base directories a Windows candidate path can resolve against. +const ( + WinAppData WinBase = iota // %APPDATA% + WinLocalAppData // %LOCALAPPDATA% + WinHome // the user's home directory +) + +// WinPath is one Windows candidate: Parts joined onto Base's directory. +type WinPath struct { + Base WinBase + Parts []string +} + +// ResolveBinary returns the path to spec's binary, searching PATH then the +// platform's candidate install locations. It returns a wrapped +// ports.ErrAgentBinaryNotFound when nothing matches, so callers surface a clear +// "command not found" rather than launching an empty argv. ctx cancellation is +// honored between probes. +func ResolveBinary(ctx context.Context, spec BinarySpec) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + names := spec.Names + var candidates []string + if runtime.GOOS == "windows" { + names = spec.WinNames + home, _ := os.UserHomeDir() + appData := os.Getenv("APPDATA") + localAppData := os.Getenv("LOCALAPPDATA") + for _, wp := range spec.WinPaths { + var base string + switch wp.Base { + case WinAppData: + base = appData + case WinLocalAppData: + base = localAppData + case WinHome: + base = home + } + if base == "" { + continue + } + candidates = append(candidates, filepath.Join(append([]string{base}, wp.Parts...)...)) + } + } else { + candidates = append(candidates, spec.UnixPaths...) + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, joinAll(home, spec.UnixHomePaths)...) + } + } + + for _, name := range names { + if err := ctx.Err(); err != nil { + return "", err + } + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + } + + for _, candidate := range candidates { + if err := ctx.Err(); err != nil { + return "", err + } + if hookutil.FileExists(candidate) { + return candidate, nil + } + } + + return "", fmt.Errorf("%s: %w", spec.Label, ports.ErrAgentBinaryNotFound) +} + +// joinAll joins each component slice onto base into an absolute candidate path. +func joinAll(base string, entries [][]string) []string { + out := make([]string, 0, len(entries)) + for _, parts := range entries { + out = append(out, filepath.Join(append([]string{base}, parts...)...)) + } + return out +} diff --git a/backend/internal/adapters/agent/binaryutil/binaryutil_test.go b/backend/internal/adapters/agent/binaryutil/binaryutil_test.go new file mode 100644 index 000000000..7cc3b24c7 --- /dev/null +++ b/backend/internal/adapters/agent/binaryutil/binaryutil_test.go @@ -0,0 +1,81 @@ +package binaryutil + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestResolveBinaryPrefersPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PATH lookup shape differs on windows") + } + dir := t.TempDir() + bin := filepath.Join(dir, "widget") + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + + got, err := ResolveBinary(context.Background(), BinarySpec{Label: "widget", Names: []string{"widget"}}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got != bin { + t.Fatalf("got %q, want %q", got, bin) + } +} + +func TestResolveBinaryFallsBackToHomeCandidate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix home candidate shape") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", t.TempDir()) // empty of the binary + bin := filepath.Join(home, ".local", "bin", "widget") + if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + got, err := ResolveBinary(context.Background(), BinarySpec{ + Label: "widget", + Names: []string{"widget"}, + UnixHomePaths: [][]string{{".local", "bin", "widget"}}, + }) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got != bin { + t.Fatalf("got %q, want %q", got, bin) + } +} + +func TestResolveBinaryNotFound(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := ResolveBinary(context.Background(), BinarySpec{ + Label: "widget", + Names: []string{"widget-does-not-exist"}, + WinNames: []string{"widget-does-not-exist.exe"}, + }) + if !errors.Is(err, ports.ErrAgentBinaryNotFound) { + t.Fatalf("want ErrAgentBinaryNotFound, got %v", err) + } +} + +func TestResolveBinaryHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := ResolveBinary(ctx, BinarySpec{Label: "widget", Names: []string{"widget"}}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("want context.Canceled, got %v", err) + } +} diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index 147fec382..a803f86aa 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -24,7 +24,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "sync" "time" @@ -32,6 +31,8 @@ import ( "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" ) @@ -51,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 } @@ -177,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 @@ -261,15 +254,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 } // AuthStatus checks Claude Code's local authentication state without starting a @@ -409,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: @@ -435,74 +421,24 @@ func appendToolFlags(cmd *[]string, allowed, disallowed []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 settings.json (no flag). - return ports.PermissionModeDefault - } +// 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 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. +// 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) { @@ -609,8 +545,3 @@ func ensureWorkspaceTrusted(configPath, workspacePath string) error { } return nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index eb2fc98ba..e0f21d1eb 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -183,8 +184,8 @@ func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) { t.Fatal(err) } var config struct { - Hooks map[string][]claudeMatcherGroup `json:"hooks"` - Permissions json.RawMessage `json:"permissions"` + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` + Permissions json.RawMessage `json:"permissions"` } if err := json.Unmarshal(data, &config); err != nil { t.Fatal(err) @@ -260,8 +261,8 @@ func TestUninstallHooksRemovesClaudeHooks(t *testing.T) { t.Fatal(err) } var config struct { - Hooks map[string][]claudeMatcherGroup `json:"hooks"` - Permissions json.RawMessage `json:"permissions"` + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` + Permissions json.RawMessage `json:"permissions"` } if err := json.Unmarshal(data, &config); err != nil { t.Fatal(err) @@ -343,7 +344,7 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { // countClaudeHookCommand counts how many hook entries under one event register // the given command — used to prove no duplicate AO hooks. -func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int { +func countClaudeHookCommand(groups []hooksjson.MatcherGroup, command string) int { count := 0 for _, group := range groups { for _, hook := range group.Hooks { @@ -357,7 +358,7 @@ func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int { // matcherForCommand returns the matcher on the group that registers the given // command (nil if the group has no matcher). -func matcherForCommand(groups []claudeMatcherGroup, command string) *string { +func matcherForCommand(groups []hooksjson.MatcherGroup, command string) *string { for _, group := range groups { for _, hook := range group.Hooks { if hook.Command == command { diff --git a/backend/internal/adapters/agent/claudecode/hooks.go b/backend/internal/adapters/agent/claudecode/hooks.go index da8fbb6a6..88019cdb3 100644 --- a/backend/internal/adapters/agent/claudecode/hooks.go +++ b/backend/internal/adapters/agent/claudecode/hooks.go @@ -2,64 +2,25 @@ package claudecode import ( "context" - "encoding/json" - "errors" - "fmt" - "os" "path/filepath" - "strings" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( - claudeSettingsDirName = ".claude" - claudeSettingsFileName = "settings.local.json" - - // claudeHookCommandPrefix identifies the hook commands AO owns. Every - // managed command starts with it, so install can skip duplicates and - // uninstall can recognize AO entries by prefix without an embedded - // template to diff against. + claudeSettingsDirName = ".claude" + claudeSettingsFileName = "settings.local.json" claudeHookCommandPrefix = "ao hooks claude-code " claudeHookTimeout = 30 ) -type claudeMatcherGroup struct { - // Matcher is a pointer so it round-trips exactly: SessionStart requires a - // real matcher ("startup"); UserPromptSubmit/Stop omit it (Claude ignores - // matcher for those events). omitempty drops a nil matcher on write. - Matcher *string `json:"matcher,omitempty"` - Hooks []claudeHookEntry `json:"hooks"` -} - -type claudeHookEntry struct { - Type string `json:"type"` - Command string `json:"command"` - Timeout int `json:"timeout,omitempty"` -} - -// claudeHookSpec describes one hook AO installs, defined in code rather -// than read from an embedded settings file. -type claudeHookSpec struct { - Event string - Matcher *string - Command string -} - // claudeStartupMatcher is referenced by pointer so SessionStart serializes with // its required "startup" matcher. var claudeStartupMatcher = "startup" -// claudeManagedHooks is the source of truth for the hooks AO installs: -// SessionStart (under the "startup" matcher), UserPromptSubmit, Stop, -// Notification, and SessionEnd. They report normalized session metadata and -// activity-state signals back into AO's store (see DeriveActivityState). -// Notification and SessionEnd carry no matcher: each installs once and fires -// for every sub-type, and the handler filters on the payload's -// notification_type / reason field — installing one command under multiple -// matchers would trip the per-command dedup in claudeHookCommandExists. -var claudeManagedHooks = []claudeHookSpec{ +// claudeManagedHooks is the source of truth for the hooks AO installs. +var claudeManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"}, {Event: "Stop", Command: claudeHookCommandPrefix + "stop"}, @@ -67,282 +28,31 @@ var claudeManagedHooks = []claudeHookSpec{ {Event: "SessionEnd", Command: claudeHookCommandPrefix + "session-end"}, } -// GetAgentHooks installs AO's Claude Code hooks into the worktree-local -// .claude/settings.local.json file (the per-session local settings, not the -// shared .claude/settings.json). The hooks (SessionStart, UserPromptSubmit, -// Stop, Notification, SessionEnd) report normalized session metadata and -// activity-state signals back into AO's store. Existing hooks and unrelated -// settings are preserved, and duplicate AO commands are not appended, so -// the install is idempotent. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(cfg.WorkspacePath) == "" { - return errors.New("claude-code.GetAgentHooks: WorkspacePath is required") - } - - settingsPath := claudeSettingsPath(cfg.WorkspacePath) - topLevel, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - - for event, specs := range groupClaudeHooksByEvent() { - var existingGroups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &existingGroups); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - for _, spec := range specs { - if !claudeHookCommandExists(existingGroups, spec.Command) { - entry := claudeHookEntry{Type: "command", Command: spec.Command, Timeout: claudeHookTimeout} - existingGroups = addClaudeHook(existingGroups, entry, spec.Matcher) - } - } - if err := marshalClaudeHookType(rawHooks, event, existingGroups); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - } - - if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(settingsPath), claudeSettingsFileName); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: gitignore: %w", err) - } - return nil -} - -// UninstallHooks removes AO's Claude Code hooks from the workspace-local -// .claude/settings.local.json file, leaving user-defined hooks and unrelated -// settings untouched. A missing settings file is a no-op. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(workspacePath) == "" { - return errors.New("claude-code.UninstallHooks: workspacePath is required") - } - - settingsPath := claudeSettingsPath(workspacePath) - if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { - return nil - } - topLevel, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - - for _, event := range claudeManagedEvents() { - var groups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &groups); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - groups = removeClaudeManagedHooks(groups) - if err := marshalClaudeHookType(rawHooks, event, groups); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - } - - if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - return nil -} - -// AreHooksInstalled reports whether any AO Claude Code hook is present in -// the workspace-local settings file. A missing file means none are installed. -func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - if strings.TrimSpace(workspacePath) == "" { - return false, errors.New("claude-code.AreHooksInstalled: workspacePath is required") - } - - settingsPath := claudeSettingsPath(workspacePath) - if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { - return false, nil - } - _, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err) - } - - for _, event := range claudeManagedEvents() { - var groups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &groups); err != nil { - return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err) - } - for _, group := range groups { - for _, hook := range group.Hooks { - if isClaudeManagedHook(hook.Command) { - return true, nil - } - } - } - } - return false, nil +// claudeHooks manages AO's hooks in the workspace-local +// .claude/settings.local.json file. +var claudeHooks = hooksjson.Manager{ + Label: "claude-code", + CommandPrefix: claudeHookCommandPrefix, + Timeout: claudeHookTimeout, + Path: claudeSettingsPath, + Managed: claudeManagedHooks, } func claudeSettingsPath(workspacePath string) string { return filepath.Join(workspacePath, claudeSettingsDirName, claudeSettingsFileName) } -// readClaudeSettings loads the settings file into a top-level raw map plus the -// decoded "hooks" sub-map, preserving every key AO doesn't manage. A -// missing or empty file yields empty maps. -func readClaudeSettings(settingsPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { - topLevel = map[string]json.RawMessage{} - rawHooks = map[string]json.RawMessage{} - - data, err := os.ReadFile(settingsPath) //nolint:gosec // path built from caller-owned workspace dir - if errors.Is(err, os.ErrNotExist) { - return topLevel, rawHooks, nil - } - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", settingsPath, err) - } - if strings.TrimSpace(string(data)) == "" { - return topLevel, rawHooks, nil - } - if err := json.Unmarshal(data, &topLevel); err != nil { - return nil, nil, fmt.Errorf("parse %s: %w", settingsPath, err) - } - if hooksRaw, ok := topLevel["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return nil, nil, fmt.Errorf("parse hooks in %s: %w", settingsPath, err) - } - } - return topLevel, rawHooks, nil +// GetAgentHooks installs AO's Claude Code hooks, preserving user-defined hooks and unrelated settings. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return claudeHooks.Install(ctx, cfg.WorkspacePath) } -// writeClaudeSettings folds rawHooks back into topLevel and writes the file. An -// empty hooks map drops the "hooks" key entirely. -func writeClaudeSettings(settingsPath string, topLevel, rawHooks map[string]json.RawMessage) error { - if len(rawHooks) == 0 { - delete(topLevel, "hooks") - } else { - hooksJSON, err := json.Marshal(rawHooks) - if err != nil { - return fmt.Errorf("encode hooks: %w", err) - } - topLevel["hooks"] = hooksJSON - } - - if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { - return fmt.Errorf("create settings dir: %w", err) - } - data, err := json.MarshalIndent(topLevel, "", " ") - if err != nil { - return fmt.Errorf("encode %s: %w", settingsPath, err) - } - data = append(data, '\n') - if err := hookutil.AtomicWriteFile(settingsPath, data, 0o600); err != nil { - return fmt.Errorf("write %s: %w", settingsPath, err) - } - return nil +// UninstallHooks removes AO's Claude Code hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return claudeHooks.Uninstall(ctx, workspacePath) } -// groupClaudeHooksByEvent groups the managed hook specs by their Claude event so -// each event's settings array is rewritten once. -func groupClaudeHooksByEvent() map[string][]claudeHookSpec { - byEvent := map[string][]claudeHookSpec{} - for _, spec := range claudeManagedHooks { - byEvent[spec.Event] = append(byEvent[spec.Event], spec) - } - return byEvent -} - -// claudeManagedEvents returns the distinct Claude events AO manages, in -// the order they first appear in claudeManagedHooks. -func claudeManagedEvents() []string { - seen := map[string]bool{} - events := make([]string, 0, len(claudeManagedHooks)) - for _, spec := range claudeManagedHooks { - if !seen[spec.Event] { - seen[spec.Event] = true - events = append(events, spec.Event) - } - } - return events -} - -func isClaudeManagedHook(command string) bool { - return strings.HasPrefix(command, claudeHookCommandPrefix) -} - -// removeClaudeManagedHooks strips AO hook entries from every group, -// dropping any group left without hooks so the event array doesn't accumulate -// empty matcher objects. -func removeClaudeManagedHooks(groups []claudeMatcherGroup) []claudeMatcherGroup { - result := make([]claudeMatcherGroup, 0, len(groups)) - for _, group := range groups { - kept := make([]claudeHookEntry, 0, len(group.Hooks)) - for _, hook := range group.Hooks { - if !isClaudeManagedHook(hook.Command) { - kept = append(kept, hook) - } - } - if len(kept) > 0 { - group.Hooks = kept - result = append(result, group) - } - } - return result -} - -func parseClaudeHookType(rawHooks map[string]json.RawMessage, event string, target *[]claudeMatcherGroup) error { - data, ok := rawHooks[event] - if !ok { - return nil - } - if err := json.Unmarshal(data, target); err != nil { - return fmt.Errorf("parse %s hooks: %w", event, err) - } - return nil -} - -func marshalClaudeHookType(rawHooks map[string]json.RawMessage, event string, groups []claudeMatcherGroup) error { - if len(groups) == 0 { - delete(rawHooks, event) - return nil - } - data, err := json.Marshal(groups) - if err != nil { - return fmt.Errorf("encode %s hooks: %w", event, err) - } - rawHooks[event] = data - return nil -} - -func claudeHookCommandExists(groups []claudeMatcherGroup, command string) bool { - for _, group := range groups { - for _, hook := range group.Hooks { - if hook.Command == command { - return true - } - } - } - return false -} - -// addClaudeHook appends hook to an existing group with the same matcher (so a -// SessionStart hook lands under its "startup" matcher), creating that group if -// none matches. -func addClaudeHook(groups []claudeMatcherGroup, hook claudeHookEntry, matcher *string) []claudeMatcherGroup { - for i, group := range groups { - if matchersEqual(group.Matcher, matcher) { - groups[i].Hooks = append(groups[i].Hooks, hook) - return groups - } - } - return append(groups, claudeMatcherGroup{Matcher: matcher, Hooks: []claudeHookEntry{hook}}) -} - -func matchersEqual(a, b *string) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return *a == *b +// AreHooksInstalled reports whether any AO Claude Code hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return claudeHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/cline/activity.go b/backend/internal/adapters/agent/cline/activity.go deleted file mode 100644 index 5d51238f0..000000000 --- a/backend/internal/adapters/agent/cline/activity.go +++ /dev/null @@ -1,32 +0,0 @@ -package cline - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Cline hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed by clineManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), not -// the native Cline event name. Cline currently exposes no stable -// session/process-end hook the adapter installs, so runtime exit still falls -// back to the lifecycle reaper. -// -// TODO(cline): ActivityExited is still runtime-observation-owned. If Cline adds -// a stable native session/process-end hook (e.g. session_shutdown via the CLI -// `cline hook` path), map it to ActivityExited here. Until then, ensure the -// reaper can still mark a dead Cline runtime as exited even when the last hook -// signal was sticky waiting_input. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/cline/cline.go b/backend/internal/adapters/agent/cline/cline.go index 9c032f3c0..f2fbde636 100644 --- a/backend/internal/adapters/agent/cline/cline.go +++ b/backend/internal/adapters/agent/cline/cline.go @@ -16,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 } @@ -61,14 +54,6 @@ 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 Cline session. Prompted // launches request machine-readable NDJSON output (`--json`). Promptless // launches stay interactive because Cline's JSON output mode requires a prompt @@ -97,15 +82,6 @@ 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 [approval flags] --id `. Resumes are interactive // because no prompt is supplied here. ok is false when the hook-derived native @@ -138,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) { @@ -233,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: @@ -249,20 +170,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--yolo") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/cline/cline_test.go b/backend/internal/adapters/agent/cline/cline_test.go index 2ef2c6b7c..ec1e359e9 100644 --- a/backend/internal/adapters/agent/cline/cline_test.go +++ b/backend/internal/adapters/agent/cline/cline_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -114,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 { @@ -126,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 { @@ -245,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") } } @@ -324,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", }, }) @@ -367,29 +367,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { } } -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - event string - want domain.ActivityState - wantOK bool - }{ - {"session-start", domain.ActivityActive, true}, - {"user-prompt-submit", domain.ActivityActive, true}, - {"stop", domain.ActivityIdle, true}, - {"permission-request", domain.ActivityWaitingInput, true}, - {"unknown", "", false}, - {"", "", false}, - } - for _, tt := range tests { - t.Run(tt.event, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, nil) - if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, got, ok, tt.want, tt.wantOK) - } - }) - } -} - func TestContextCancellationIsHonored(t *testing.T) { plugin := &Plugin{resolvedBinary: "cline"} ctx, cancel := context.WithCancel(context.Background()) diff --git a/backend/internal/adapters/agent/cline/hooks.go b/backend/internal/adapters/agent/cline/hooks.go index 53df0d0b7..184d4fe8c 100644 --- a/backend/internal/adapters/agent/cline/hooks.go +++ b/backend/internal/adapters/agent/cline/hooks.go @@ -83,11 +83,11 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi for _, spec := range clineManagedHooks { scriptPath := filepath.Join(hooksDir, spec.Event) // Never clobber a user-authored hook with the same event name. - if fileExists(scriptPath) && !isManagedClineHook(scriptPath) { + if hookutil.FileExists(scriptPath) && !isManagedClineHook(scriptPath) { continue } script := renderClineHookScript(spec.Subcommand) - if err := atomicWriteFile(scriptPath, []byte(script), 0o700); err != nil { + if err := hookutil.AtomicWriteFile(scriptPath, []byte(script), 0o700); err != nil { return fmt.Errorf("cline.GetAgentHooks: write %s: %w", spec.Event, err) } written = append(written, spec.Event) @@ -116,7 +116,7 @@ func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error for _, spec := range clineManagedHooks { scriptPath := filepath.Join(hooksDir, spec.Event) - if !fileExists(scriptPath) || !isManagedClineHook(scriptPath) { + if !hookutil.FileExists(scriptPath) || !isManagedClineHook(scriptPath) { continue } if err := os.Remove(scriptPath); err != nil && !errors.Is(err, os.ErrNotExist) { @@ -143,7 +143,7 @@ func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (b for _, spec := range clineManagedHooks { scriptPath := filepath.Join(hooksDir, spec.Event) - if fileExists(scriptPath) && isManagedClineHook(scriptPath) { + if hookutil.FileExists(scriptPath) && isManagedClineHook(scriptPath) { return true, nil } } @@ -177,26 +177,3 @@ func isManagedClineHook(scriptPath string) bool { } return strings.Contains(string(data), clineHookMarker) } - -// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- -// write can't leave a truncated script that Cline then fails to execute. -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} diff --git a/backend/internal/adapters/agent/codex/codex.go b/backend/internal/adapters/agent/codex/codex.go index 8ce77fbd3..8985c0ec6 100644 --- a/backend/internal/adapters/agent/codex/codex.go +++ b/backend/internal/adapters/agent/codex/codex.go @@ -19,12 +19,14 @@ import ( "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 } @@ -51,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 @@ -92,15 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Codex receives its prompt in the -// launch command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Codex // session: `codex resume `. ok is false when the hook-derived // native session id has not landed yet, so callers can fall back to fresh @@ -138,15 +123,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 } // AuthStatus checks Codex's local login state without making a model call. @@ -340,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 @@ -355,18 +333,7 @@ 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 - } -} - +// fileExists is a package var so tests can stub it to scope candidate probing. var fileExists = func(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() diff --git a/backend/internal/adapters/agent/codex/codex_test.go b/backend/internal/adapters/agent/codex/codex_test.go index e4348f62f..49b60a538 100644 --- a/backend/internal/adapters/agent/codex/codex_test.go +++ b/backend/internal/adapters/agent/codex/codex_test.go @@ -233,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 { @@ -245,7 +245,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "codex"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { diff --git a/backend/internal/adapters/agent/continueagent/continueagent.go b/backend/internal/adapters/agent/continueagent/continueagent.go index 65f4122ea..0d82c3c4e 100644 --- a/backend/internal/adapters/agent/continueagent/continueagent.go +++ b/backend/internal/adapters/agent/continueagent/continueagent.go @@ -22,15 +22,12 @@ package continueagent import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -39,9 +36,22 @@ import ( // (NOT the Go package name "continueagent"). const adapterID = "continue" +var continueBinarySpec = binaryutil.BinarySpec{ + Label: "cn", + Names: []string{"cn"}, + WinNames: []string{"cn.cmd", "cn.exe", "cn"}, + UnixPaths: []string{"/usr/local/bin/cn", "/opt/homebrew/bin/cn"}, + UnixHomePaths: [][]string{{".npm-global", "bin", "cn"}, {".local", "bin", "cn"}, {".npm", "bin", "cn"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.exe"}}, + }, +} + // Plugin is the Continue CLI agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -67,14 +77,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds `cn --print [--auto|--readonly] `. // // `--print` runs Continue in non-interactive (headless) mode. The prompt is the @@ -97,14 +99,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetAgentHooks reuses the Claude Code hook installer because the Continue CLI // natively reads Claude Code hook settings. // @@ -154,15 +148,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveContinueBinary finds the `cn` binary (Continue CLI), searching PATH then @@ -170,63 +157,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por // callers get the shell's normal command-not-found behavior if Continue is // absent. func ResolveContinueBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"cn.cmd", "cn.exe", "cn"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "cn.cmd"), - filepath.Join(appData, "npm", "cn.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("cn"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/cn", - "/opt/homebrew/bin/cn", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".npm-global", "bin", "cn"), - filepath.Join(home, ".local", "bin", "cn"), - filepath.Join(home, ".npm", "bin", "cn"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, continueBinarySpec) } func (p *Plugin) continueBinary(ctx context.Context) (string, error) { @@ -251,7 +182,7 @@ func (p *Plugin) continueBinary(ctx context.Context) (string, error) { // and the two flags are mutually exclusive. Default and AcceptEdits emit no flag // so Continue defers to the user's own config / default behavior. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Continue config / default behavior. case ports.PermissionModeAcceptEdits: @@ -262,20 +193,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--auto") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/copilot/activity.go b/backend/internal/adapters/agent/copilot/activity.go deleted file mode 100644 index a32110547..000000000 --- a/backend/internal/adapters/agent/copilot/activity.go +++ /dev/null @@ -1,38 +0,0 @@ -package copilot - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Copilot CLI hook event onto an AO activity state. -// The bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in copilotManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), NOT the -// native Copilot event name. Keeping this beside hooks.go means the events AO -// installs and what they mean live in one place. -// -// Copilot CLI documents that prompt-style hooks (userPromptSubmitted) do NOT -// fire in non-interactive `-p` mode, while preToolUse fires before every tool -// invocation (including ones that would prompt the user for approval) and is -// the most reliable signal in CLI pipe mode (-p). AO still installs every event -// so interactive resume and future modes report activity; the -// permission-request → waiting_input mapping (driven by preToolUse) is the one -// that always fires under AO's headless launch. -// -// TODO(copilot): ActivityExited is still runtime-observation-owned. If Copilot's -// sessionEnd/agentStop hook proves reliable in `-p` mode, map a real -// session-end here. Until then, the lifecycle reaper marks a dead Copilot -// runtime exited even when the last hook signal was sticky waiting_input. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/copilot/copilot.go b/backend/internal/adapters/agent/copilot/copilot.go index 3a38de564..043023e1b 100644 --- a/backend/internal/adapters/agent/copilot/copilot.go +++ b/backend/internal/adapters/agent/copilot/copilot.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,14 +64,6 @@ 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 interactive Copilot session: // // copilot [permission flags] @@ -95,7 +85,9 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( } // GetPromptDeliveryStrategy reports that Copilot receives its prompt after the -// interactive process starts. +// 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 @@ -138,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 @@ -179,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 { @@ -211,7 +198,7 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { if native := copilotNativeBinaryForLoader(candidate); native != "" { return native, nil } @@ -238,7 +225,7 @@ func copilotNativeBinaryForLoader(path string) string { platform = "darwin" } native := filepath.Join(filepath.Dir(resolved), "node_modules", ".bin", "copilot-"+platform+"-"+runtime.GOARCH) - if fileExists(native) { + if hookutil.FileExists(native) { return native } return "" @@ -263,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: @@ -279,20 +266,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--allow-all") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go index a81b8b520..711e37209 100644 --- a/backend/internal/adapters/agent/copilot/copilot_test.go +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -9,7 +9,6 @@ import ( "runtime" "testing" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -118,7 +117,7 @@ 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 { @@ -130,7 +129,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "copilot"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -309,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", }, }) @@ -507,29 +506,6 @@ func TestCopilotManagedHooksUseDocumentedEventNames(t *testing.T) { } } -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - event string - wantState domain.ActivityState - wantOK bool - }{ - {"session-start", domain.ActivityActive, true}, - {"user-prompt-submit", domain.ActivityActive, true}, - {"stop", domain.ActivityIdle, true}, - {"permission-request", domain.ActivityWaitingInput, true}, - {"unknown", "", false}, - {"", "", false}, - } - for _, tt := range tests { - t.Run(tt.event, func(t *testing.T) { - state, ok := DeriveActivityState(tt.event, nil) - if state != tt.wantState || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, state, ok, tt.wantState, tt.wantOK) - } - }) - } -} - func contains(values []string, needle string) bool { for _, value := range values { if value == needle { diff --git a/backend/internal/adapters/agent/copilot/hooks.go b/backend/internal/adapters/agent/copilot/hooks.go index 65e2e280d..39f02ae61 100644 --- a/backend/internal/adapters/agent/copilot/hooks.go +++ b/backend/internal/adapters/agent/copilot/hooks.go @@ -238,40 +238,12 @@ func writeCopilotHooks(hooksPath string, file copilotHookFile) error { return fmt.Errorf("encode %s: %w", hooksPath, err) } data = append(data, '\n') - if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { + if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { return fmt.Errorf("write %s: %w", hooksPath, err) } return nil } -// atomicWriteFile writes data to path via a temp file in the same directory -// followed by a rename, so a crash or signal mid-write can't leave a truncated -// or empty file that Copilot then fails to parse (silently disabling hooks). -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() // no-op once renamed - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} - // isCopilotManagedHook reports whether an entry is one AO owns, recognized by the // command prefix on either the bash or powershell command. func isCopilotManagedHook(entry copilotHookEntry) bool { diff --git a/backend/internal/adapters/agent/crush/crush.go b/backend/internal/adapters/agent/crush/crush.go index 6f192c8a3..3971e573c 100644 --- a/backend/internal/adapters/agent/crush/crush.go +++ b/backend/internal/adapters/agent/crush/crush.go @@ -8,15 +8,12 @@ package crush import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -29,6 +26,7 @@ const ( // Plugin is the Crush agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -54,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Crush exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start an interactive Crush session. // Shape: // @@ -102,15 +92,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Crush receives its prompt in the -// launch command itself as a positional argument. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Crush session: // `crush [--cwd ] [--yolo] --session `. // It re-applies the permission flag but not the prompt, which the session @@ -143,83 +124,24 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo surfaces Crush session metadata. Currently a no-op since Crush -// doesn't have full hooks support like Claude Code and Codex. Returns false -// to indicate no metadata is available. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - // No-op for now since Crush doesn't have full hooks support - return ports.SessionInfo{}, false, nil +var crushBinarySpec = binaryutil.BinarySpec{ + Label: "crush", + Names: []string{"crush"}, + WinNames: []string{"crush.cmd", "crush.exe", "crush"}, + UnixPaths: []string{"/usr/local/bin/crush", "/opt/homebrew/bin/crush"}, + UnixHomePaths: [][]string{{".local", "bin", "crush"}, {".cargo", "bin", "crush"}, {".npm", "bin", "crush"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "crush.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "crush.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "crush.exe"}}, + }, } // ResolveCrushBinary returns the path to the crush binary on this machine, -// searching PATH then a handful of well-known install locations. -// Returns "crush" as a last-ditch fallback. +// searching PATH then a handful of well-known install locations. It returns a +// wrapped ports.ErrAgentBinaryNotFound when crush is absent. func ResolveCrushBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"crush.cmd", "crush.exe", "crush"} { - path, err := exec.LookPath(name) - if err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "crush.cmd"), - filepath.Join(appData, "npm", "crush.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "crush.exe")) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("crush: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("crush"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/crush", - "/opt/homebrew/bin/crush", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "crush"), - filepath.Join(home, ".cargo", "bin", "crush"), - filepath.Join(home, ".npm", "bin", "crush"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("crush: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, crushBinarySpec) } func (p *Plugin) crushBinary(ctx context.Context) (string, error) { @@ -237,8 +159,3 @@ func (p *Plugin) crushBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/crush/crush_test.go b/backend/internal/adapters/agent/crush/crush_test.go index 45756dc14..b7cb1b26e 100644 --- a/backend/internal/adapters/agent/crush/crush_test.go +++ b/backend/internal/adapters/agent/crush/crush_test.go @@ -90,7 +90,7 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "crush"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { diff --git a/backend/internal/adapters/agent/cursor/activity.go b/backend/internal/adapters/agent/cursor/activity.go deleted file mode 100644 index 068ce985a..000000000 --- a/backend/internal/adapters/agent/cursor/activity.go +++ /dev/null @@ -1,30 +0,0 @@ -package cursor - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Cursor hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in cursorManagedHooks -// ("session-start", "user-prompt-submit", "stop", "permission-request"), not -// the native Cursor event name. Cursor currently has no SessionEnd/Notification -// equivalent in the adapter, so runtime exit still falls back to the reaper. -// -// TODO(cursor): ActivityExited is still runtime-observation-owned. If Cursor -// adds a native session/process-end hook, map that hook to ActivityExited here. -// Until then, make sure the lifecycle reaper can still mark a dead Cursor -// runtime as exited even when the last hook signal was sticky waiting_input. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/cursor/activity_test.go b/backend/internal/adapters/agent/cursor/activity_test.go deleted file mode 100644 index 9b9e13227..000000000 --- a/backend/internal/adapters/agent/cursor/activity_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package cursor - -import ( - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - name string - event string - want domain.ActivityState - wantOK bool - }{ - {"session start -> active", "session-start", domain.ActivityActive, true}, - {"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true}, - {"stop -> idle", "stop", domain.ActivityIdle, true}, - {"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true}, - {"unknown event -> no signal", "frobnicate", "", false}, - {"native event name -> no signal", "beforeShellExecution", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, []byte(`{}`)) - if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", - tt.event, got, ok, tt.want, tt.wantOK) - } - }) - } -} diff --git a/backend/internal/adapters/agent/cursor/cursor.go b/backend/internal/adapters/agent/cursor/cursor.go index ecc883556..9b9dc9dfd 100644 --- a/backend/internal/adapters/agent/cursor/cursor.go +++ b/backend/internal/adapters/agent/cursor/cursor.go @@ -18,17 +18,15 @@ import ( "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - cursorTitleMetadataKey = "title" - cursorSummaryMetadataKey = "summary" -) - // Plugin is the Cursor agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -54,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Cursor exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new Cursor CLI session: // // cursor-agent -p --output-format stream-json --trust [permission flags] @@ -92,15 +82,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Cursor receives its prompt in the -// launch command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Cursor CLI // session: // @@ -136,15 +117,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[cursorTitleMetadataKey], - Summary: session.Metadata[cursorSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveCursorBinary returns the path to the cursor-agent binary on this @@ -183,7 +157,7 @@ func ResolveCursorBinary(ctx context.Context) (string, error) { ) for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -211,7 +185,7 @@ func (p *Plugin) cursorBinary(ctx context.Context) (string, error) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Cursor config approvalMode. case ports.PermissionModeAcceptEdits: @@ -223,20 +197,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--yolo") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/cursor/cursor_test.go b/backend/internal/adapters/agent/cursor/cursor_test.go index 6600a9cd4..3e20d24a9 100644 --- a/backend/internal/adapters/agent/cursor/cursor_test.go +++ b/backend/internal/adapters/agent/cursor/cursor_test.go @@ -113,7 +113,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cursor-agent"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -125,7 +125,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cursor-agent"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -200,8 +200,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "chat-123", - cursorTitleMetadataKey: "Fix login redirect", - cursorSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) diff --git a/backend/internal/adapters/agent/devin/devin.go b/backend/internal/adapters/agent/devin/devin.go index dade0428f..55097c34a 100644 --- a/backend/internal/adapters/agent/devin/devin.go +++ b/backend/internal/adapters/agent/devin/devin.go @@ -24,26 +24,30 @@ package devin import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - devinTitleMetadataKey = "title" - devinSummaryMetadataKey = "summary" -) +var devinBinarySpec = binaryutil.BinarySpec{ + Label: "devin", + Names: []string{"devin"}, + WinNames: []string{"devin.cmd", "devin.exe", "devin"}, + UnixPaths: []string{"/usr/local/bin/devin", "/opt/homebrew/bin/devin"}, + UnixHomePaths: [][]string{{".devin", "bin", "devin"}, {".local", "bin", "devin"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinHome, Parts: []string{".devin", "bin", "devin.exe"}}, + }, +} // Plugin is the Devin for Terminal agent adapter. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -69,14 +73,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds `devin [--permission-mode ] -p `. // Prompt is delivered via -p (in command, non-interactive print mode). // @@ -99,14 +95,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetAgentHooks reuses the Claude Code hook installer because Devin for Terminal // has a documented Claude Code compatibility layer. // @@ -161,74 +149,13 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[devinTitleMetadataKey], - Summary: session.Metadata[devinSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveDevinBinary finds the `devin` binary (Cognition Devin for Terminal CLI). func ResolveDevinBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"devin.cmd", "devin.exe", "devin"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".devin", "bin", "devin.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("devin: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("devin"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/devin", - "/opt/homebrew/bin/devin", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".devin", "bin", "devin"), - filepath.Join(home, ".local", "bin", "devin"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("devin: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, devinBinarySpec) } func (p *Plugin) devinBinary(ctx context.Context) (string, error) { @@ -251,7 +178,7 @@ func (p *Plugin) devinBinary(ctx context.Context) (string, error) { // permission values (`auto`/normal and `dangerous`/bypass), per // `devin --permission-mode -h`. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to ~/.config/devin/config.json (default mode is auto). case ports.PermissionModeAcceptEdits: @@ -264,20 +191,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--permission-mode", "dangerous") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/devin/devin_test.go b/backend/internal/adapters/agent/devin/devin_test.go index 26159b811..9fe36cf07 100644 --- a/backend/internal/adapters/agent/devin/devin_test.go +++ b/backend/internal/adapters/agent/devin/devin_test.go @@ -195,8 +195,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "devin-ses-1", - devinTitleMetadataKey: "Fix login redirect", - devinSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", }, }) if err != nil { diff --git a/backend/internal/adapters/agent/droid/droid.go b/backend/internal/adapters/agent/droid/droid.go index 7643ab141..a06f0e02f 100644 --- a/backend/internal/adapters/agent/droid/droid.go +++ b/backend/internal/adapters/agent/droid/droid.go @@ -23,28 +23,21 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - // Normalized session-metadata keys the hooks persist into the AO session - // store and SessionInfo reads back. Shared vocabulary with the Codex, Grok, - // and opencode adapters so the dashboard treats every agent uniformly. - droidTitleMetadataKey = "title" - droidSummaryMetadataKey = "summary" -) - // Plugin is the Droid agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -70,14 +63,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new interactive Droid session: // // droid [--settings ] [--append-system-prompt[-file] ] [prompt] @@ -115,15 +100,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Droid receives its prompt in the launch -// command itself (the positional prompt argument). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Droid session: // `droid [--settings ] -r `. It re-applies the permission // autonomy (resume otherwise reverts to the configured default) but not the @@ -161,15 +137,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[droidTitleMetadataKey], - Summary: session.Metadata[droidSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // droidAutonomyLevel maps an AO permission mode onto Droid's @@ -182,7 +151,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por // bypass-permissions → high (max interactive autonomy; Droid's interactive // TUI has no exec-style --skip-permissions-unsafe) func droidAutonomyLevel(mode ports.PermissionMode) string { - switch normalizePermissionMode(mode) { + switch ports.NormalizePermissionMode(mode) { case ports.PermissionModeAcceptEdits: return "low" case ports.PermissionModeAuto: @@ -249,73 +218,24 @@ func sanitizeSessionID(id string) string { return b.String() } -// ResolveDroidBinary finds the `droid` binary (Factory Droid CLI), searching -// PATH then a handful of well-known install locations. Returns "droid" as a -// last-ditch fallback so callers see a clear "command not found" rather than an -// empty argv. +var droidBinarySpec = binaryutil.BinarySpec{ + Label: "droid", + Names: []string{"droid"}, + WinNames: []string{"droid.cmd", "droid.exe", "droid"}, + UnixPaths: []string{"/usr/local/bin/droid", "/opt/homebrew/bin/droid"}, + UnixHomePaths: [][]string{{".local", "bin", "droid"}, {".factory", "bin", "droid"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".local", "bin", "droid.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".factory", "bin", "droid.exe"}}, + }, +} + +// ResolveDroidBinary returns the path to the droid binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveDroidBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"droid.cmd", "droid.exe", "droid"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "droid.cmd"), - filepath.Join(appData, "npm", "droid.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "droid.exe"), - filepath.Join(home, ".factory", "bin", "droid.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("droid"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/droid", - "/opt/homebrew/bin/droid", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "droid"), - filepath.Join(home, ".factory", "bin", "droid"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, droidBinarySpec) } func (p *Plugin) droidBinary(ctx context.Context) (string, error) { @@ -333,21 +253,3 @@ func (p *Plugin) droidBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - // Empty or unrecognized: defer to Droid's own settings (no flag). - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/droid/droid_test.go b/backend/internal/adapters/agent/droid/droid_test.go index 2607372c3..2c47c3bae 100644 --- a/backend/internal/adapters/agent/droid/droid_test.go +++ b/backend/internal/adapters/agent/droid/droid_test.go @@ -178,8 +178,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "droid-ses-1", - droidTitleMetadataKey: "Fix login redirect", - droidSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", }, }) if err != nil { diff --git a/backend/internal/adapters/agent/droid/hooks.go b/backend/internal/adapters/agent/droid/hooks.go index 55c4f561c..4b55ebf4a 100644 --- a/backend/internal/adapters/agent/droid/hooks.go +++ b/backend/internal/adapters/agent/droid/hooks.go @@ -2,15 +2,9 @@ package droid import ( "context" - "encoding/json" - "errors" - "fmt" - "os" "path/filepath" - "sort" - "strings" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -18,49 +12,20 @@ const ( droidSettingsDirName = ".factory" droidHooksFileName = "hooks.json" - // droidHookCommandPrefix identifies the hook commands AO owns. Every managed - // command starts with it, so install can skip duplicates and uninstall can - // recognize AO entries by prefix without an embedded template to diff - // against. The CLI dispatcher routes `ao hooks droid ` to the Droid - // activity deriver. + // droidHookCommandPrefix identifies the hook commands AO owns, so install + // skips duplicates and uninstall recognizes AO entries by prefix. droidHookCommandPrefix = "ao hooks droid " droidHookTimeout = 30 ) -type droidMatcherGroup struct { - // Matcher is a pointer so it round-trips exactly: SessionStart serializes - // with its "startup" matcher; UserPromptSubmit/Stop/Notification/SessionEnd - // omit it (Droid ignores matcher for those events). omitempty drops a nil - // matcher on write. - Matcher *string `json:"matcher,omitempty"` - Hooks []droidHookEntry `json:"hooks"` -} - -type droidHookEntry struct { - Type string `json:"type"` - Command string `json:"command"` - Timeout int `json:"timeout,omitempty"` -} - -// droidHookSpec describes one hook AO installs, defined in code rather than read -// from an embedded settings file. -type droidHookSpec struct { - Event string - Matcher *string - Command string -} - // droidStartupMatcher is referenced by pointer so SessionStart serializes with // its "startup" source matcher. var droidStartupMatcher = "startup" // droidManagedHooks is the source of truth for the hooks AO installs: // SessionStart (under the "startup" matcher), UserPromptSubmit, Stop, -// Notification, and SessionEnd. They report normalized activity-state signals -// back into AO's store (see DeriveActivityState). The non-SessionStart events -// carry no matcher: each installs once and fires for every sub-type, and the -// handler filters on the payload where it must. -var droidManagedHooks = []droidHookSpec{ +// Notification, and SessionEnd. +var droidManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Matcher: &droidStartupMatcher, Command: droidHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: droidHookCommandPrefix + "user-prompt-submit"}, {Event: "Stop", Command: droidHookCommandPrefix + "stop"}, @@ -68,287 +33,30 @@ var droidManagedHooks = []droidHookSpec{ {Event: "SessionEnd", Command: droidHookCommandPrefix + "session-end"}, } -// GetAgentHooks installs AO's Droid hooks into the worktree-local -// .factory/hooks.json file (the project-scope hooks config Droid reads). The -// hooks report normalized activity-state signals back into AO's store. Existing -// hooks and unrelated keys are preserved, and duplicate AO commands are not -// appended, so the install is idempotent. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(cfg.WorkspacePath) == "" { - return errors.New("droid.GetAgentHooks: WorkspacePath is required") - } - - hooksPath := droidHooksPath(cfg.WorkspacePath) - topLevel, rawHooks, err := readDroidHooks(hooksPath) - if err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - - byEvent := groupDroidHooksByEvent() - events := make([]string, 0, len(byEvent)) - for event := range byEvent { - events = append(events, event) - } - sort.Strings(events) - for _, event := range events { - specs := byEvent[event] - var existingGroups []droidMatcherGroup - if err := parseDroidHookType(rawHooks, event, &existingGroups); err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - for _, spec := range specs { - if !droidHookCommandExists(existingGroups, spec.Command) { - entry := droidHookEntry{Type: "command", Command: spec.Command, Timeout: droidHookTimeout} - existingGroups = addDroidHook(existingGroups, entry, spec.Matcher) - } - } - if err := marshalDroidHookType(rawHooks, event, existingGroups); err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - } - - if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), droidHooksFileName); err != nil { - return fmt.Errorf("droid.GetAgentHooks: gitignore: %w", err) - } - return nil -} - -// UninstallHooks removes AO's Droid hooks from the workspace-local -// .factory/hooks.json file, leaving user-defined hooks and unrelated keys -// untouched. A missing file is a no-op. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(workspacePath) == "" { - return errors.New("droid.UninstallHooks: workspacePath is required") - } - - hooksPath := droidHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return nil - } - topLevel, rawHooks, err := readDroidHooks(hooksPath) - if err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - - for _, event := range droidManagedEvents() { - var groups []droidMatcherGroup - if err := parseDroidHookType(rawHooks, event, &groups); err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - groups = removeDroidManagedHooks(groups) - if err := marshalDroidHookType(rawHooks, event, groups); err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - } - - if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - return nil -} - -// AreHooksInstalled reports whether any AO Droid hook is present in the -// workspace-local hooks file. A missing file means none are installed. -func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - if strings.TrimSpace(workspacePath) == "" { - return false, errors.New("droid.AreHooksInstalled: workspacePath is required") - } - - hooksPath := droidHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return false, nil - } - _, rawHooks, err := readDroidHooks(hooksPath) - if err != nil { - return false, fmt.Errorf("droid.AreHooksInstalled: %w", err) - } - - for _, event := range droidManagedEvents() { - var groups []droidMatcherGroup - if err := parseDroidHookType(rawHooks, event, &groups); err != nil { - return false, fmt.Errorf("droid.AreHooksInstalled: %w", err) - } - for _, group := range groups { - for _, hook := range group.Hooks { - if isDroidManagedHook(hook.Command) { - return true, nil - } - } - } - } - return false, nil +// droidHooks manages AO's hooks in the workspace-local .factory/hooks.json file. +var droidHooks = hooksjson.Manager{ + Label: "droid", + CommandPrefix: droidHookCommandPrefix, + Timeout: droidHookTimeout, + Path: droidHooksPath, + Managed: droidManagedHooks, } func droidHooksPath(workspacePath string) string { return filepath.Join(workspacePath, droidSettingsDirName, droidHooksFileName) } -// readDroidHooks loads the hooks file into a top-level raw map plus the decoded -// "hooks" sub-map, preserving every key AO doesn't manage. A missing or empty -// file yields empty maps. -func readDroidHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { - topLevel = map[string]json.RawMessage{} - rawHooks = map[string]json.RawMessage{} - - data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir - if errors.Is(err, os.ErrNotExist) { - return topLevel, rawHooks, nil - } - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) - } - if strings.TrimSpace(string(data)) == "" { - return topLevel, rawHooks, nil - } - if err := json.Unmarshal(data, &topLevel); err != nil { - return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) - } - if hooksRaw, ok := topLevel["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) - } - } - return topLevel, rawHooks, nil +// GetAgentHooks installs AO's Droid hooks, preserving user-defined hooks. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return droidHooks.Install(ctx, cfg.WorkspacePath) } -// writeDroidHooks folds rawHooks back into topLevel and writes the file. An -// empty hooks map drops the "hooks" key entirely. -func writeDroidHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { - if len(rawHooks) == 0 { - delete(topLevel, "hooks") - } else { - hooksJSON, err := json.Marshal(rawHooks) - if err != nil { - return fmt.Errorf("encode hooks: %w", err) - } - topLevel["hooks"] = hooksJSON - } - - if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { - return fmt.Errorf("create hooks dir: %w", err) - } - data, err := json.MarshalIndent(topLevel, "", " ") - if err != nil { - return fmt.Errorf("encode %s: %w", hooksPath, err) - } - data = append(data, '\n') - if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { - return fmt.Errorf("write %s: %w", hooksPath, err) - } - return nil +// UninstallHooks removes AO's Droid hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return droidHooks.Uninstall(ctx, workspacePath) } -// groupDroidHooksByEvent groups the managed hook specs by their Droid event so -// each event's array is rewritten once. -func groupDroidHooksByEvent() map[string][]droidHookSpec { - byEvent := map[string][]droidHookSpec{} - for _, spec := range droidManagedHooks { - byEvent[spec.Event] = append(byEvent[spec.Event], spec) - } - return byEvent -} - -// droidManagedEvents returns the distinct Droid events AO manages, in the order -// they first appear in droidManagedHooks. -func droidManagedEvents() []string { - seen := map[string]bool{} - events := make([]string, 0, len(droidManagedHooks)) - for _, spec := range droidManagedHooks { - if !seen[spec.Event] { - seen[spec.Event] = true - events = append(events, spec.Event) - } - } - return events -} - -func isDroidManagedHook(command string) bool { - return strings.HasPrefix(command, droidHookCommandPrefix) -} - -// removeDroidManagedHooks strips AO hook entries from every group, dropping any -// group left without hooks so the event array doesn't accumulate empty matcher -// objects. -func removeDroidManagedHooks(groups []droidMatcherGroup) []droidMatcherGroup { - result := make([]droidMatcherGroup, 0, len(groups)) - for _, group := range groups { - kept := make([]droidHookEntry, 0, len(group.Hooks)) - for _, hook := range group.Hooks { - if !isDroidManagedHook(hook.Command) { - kept = append(kept, hook) - } - } - if len(kept) > 0 { - group.Hooks = kept - result = append(result, group) - } - } - return result -} - -func parseDroidHookType(rawHooks map[string]json.RawMessage, event string, target *[]droidMatcherGroup) error { - data, ok := rawHooks[event] - if !ok { - return nil - } - if err := json.Unmarshal(data, target); err != nil { - return fmt.Errorf("parse %s hooks: %w", event, err) - } - return nil -} - -func marshalDroidHookType(rawHooks map[string]json.RawMessage, event string, groups []droidMatcherGroup) error { - if len(groups) == 0 { - delete(rawHooks, event) - return nil - } - data, err := json.Marshal(groups) - if err != nil { - return fmt.Errorf("encode %s hooks: %w", event, err) - } - rawHooks[event] = data - return nil -} - -func droidHookCommandExists(groups []droidMatcherGroup, command string) bool { - for _, group := range groups { - for _, hook := range group.Hooks { - if hook.Command == command { - return true - } - } - } - return false -} - -// addDroidHook appends hook to an existing group with the same matcher (so a -// SessionStart hook lands under its "startup" matcher), creating that group if -// none matches. -func addDroidHook(groups []droidMatcherGroup, hook droidHookEntry, matcher *string) []droidMatcherGroup { - for i, group := range groups { - if matchersEqual(group.Matcher, matcher) { - groups[i].Hooks = append(groups[i].Hooks, hook) - return groups - } - } - return append(groups, droidMatcherGroup{Matcher: matcher, Hooks: []droidHookEntry{hook}}) -} - -func matchersEqual(a, b *string) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return *a == *b +// AreHooksInstalled reports whether any AO Droid hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return droidHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/goose/activity.go b/backend/internal/adapters/agent/goose/activity.go deleted file mode 100644 index 183568925..000000000 --- a/backend/internal/adapters/agent/goose/activity.go +++ /dev/null @@ -1,35 +0,0 @@ -package goose - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Goose hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in gooseManagedHooks -// ("session-start", "user-prompt-submit", "stop", "permission-request"), not -// the native Goose event name. -// -// Goose's native hook surface (as of 2026-05) emits SessionStart / -// UserPromptSubmit / Stop / SessionEnd plus the tool-use events, but has no -// dedicated permission/approval event yet, so AO does not install a -// "permission-request" hook today. The case is kept here so that, if a future -// Goose release adds an approval lifecycle event, mapping it to waiting_input is -// a one-line hooks.go change with no deriver edit needed. -// -// TODO(goose): ActivityExited is still runtime-observation-owned. Goose has a -// native SessionEnd hook; if AO starts installing it, map it to ActivityExited -// here. Until then, the lifecycle reaper marks a dead Goose runtime as exited. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/goose/activity_test.go b/backend/internal/adapters/agent/goose/activity_test.go deleted file mode 100644 index 224ac8a4e..000000000 --- a/backend/internal/adapters/agent/goose/activity_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package goose - -import ( - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - name string - event string - want domain.ActivityState - wantOK bool - }{ - {"session start -> active", "session-start", domain.ActivityActive, true}, - {"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true}, - {"stop -> idle", "stop", domain.ActivityIdle, true}, - {"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true}, - {"unknown event -> no signal", "frobnicate", "", false}, - {"empty event -> no signal", "", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, []byte(`{}`)) - if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", - tt.event, got, ok, tt.want, tt.wantOK) - } - }) - } -} diff --git a/backend/internal/adapters/agent/goose/goose.go b/backend/internal/adapters/agent/goose/goose.go index faa39963a..f2f81768c 100644 --- a/backend/internal/adapters/agent/goose/goose.go +++ b/backend/internal/adapters/agent/goose/goose.go @@ -25,22 +25,18 @@ import ( "context" "fmt" "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( adapterID = "goose" - gooseTitleMetadataKey = "title" - gooseSummaryMetadataKey = "summary" - // gooseModeEnvVar is the only permission-control surface Goose honors: the // approval mode is read from this process env var, not from any CLI flag. gooseModeEnvVar = "GOOSE_MODE" @@ -49,6 +45,7 @@ const ( // Plugin is the Goose agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -74,14 +71,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Goose exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new headless Goose session: // // [env GOOSE_MODE=] goose run [--system ] -t @@ -117,15 +106,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Goose receives its prompt in the launch -// command itself (via `-t`). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Goose session: // // [env GOOSE_MODE=] goose run --resume --session-id @@ -156,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` @@ -210,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: @@ -222,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) { @@ -323,8 +232,3 @@ func (p *Plugin) gooseBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/goose/goose_test.go b/backend/internal/adapters/agent/goose/goose_test.go index 8c2da8af1..c9f8ac562 100644 --- a/backend/internal/adapters/agent/goose/goose_test.go +++ b/backend/internal/adapters/agent/goose/goose_test.go @@ -9,6 +9,7 @@ import ( "reflect" "testing" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -141,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 { @@ -153,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 { @@ -419,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", }, }) @@ -511,7 +512,13 @@ func containsSubsequence(values []string, needle []string) bool { return false } -func countGooseHookCommand(entries []gooseMatcherGroup, command string) int { +// gooseHookFile is the on-disk shape of the hooks file, used to decode and +// assert on what GetAgentHooks wrote. +type gooseHookFile struct { + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` +} + +func countGooseHookCommand(entries []hooksjson.MatcherGroup, command string) int { count := 0 for _, entry := range entries { for _, hook := range entry.Hooks { diff --git a/backend/internal/adapters/agent/goose/hooks.go b/backend/internal/adapters/agent/goose/hooks.go index f631659e5..da935f14b 100644 --- a/backend/internal/adapters/agent/goose/hooks.go +++ b/backend/internal/adapters/agent/goose/hooks.go @@ -2,22 +2,16 @@ package goose import ( "context" - "encoding/json" - "errors" - "fmt" - "os" "path/filepath" - "strings" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( - // goosePluginDirName is the AO plugin directory under a workspace's - // .agents/plugins/. Goose auto-discovers any plugin dir containing a - // hooks/hooks.json at startup; unlike Codex there is no separate feature - // flag to toggle, so installing the file is sufficient. + // Goose auto-discovers any plugin dir containing a hooks/hooks.json at + // startup; unlike Codex there is no separate feature flag to toggle, so + // installing the file is sufficient. gooseHooksRootDirName = ".agents" goosePluginsDirName = "plugins" goosePluginName = "ao" @@ -25,332 +19,45 @@ const ( gooseHooksFileName = "hooks.json" // gooseHookCommandPrefix identifies the hook commands AO owns, so install - // skips duplicates and uninstall recognizes AO entries by prefix without an - // embedded template to diff against. + // skips duplicates and uninstall recognizes AO entries by prefix. gooseHookCommandPrefix = "ao hooks goose " gooseHookTimeout = 30 ) -// gooseHookFile is the on-disk shape of .agents/plugins/ao/hooks/hooks.json. It -// is used by tests to decode the written file. -type gooseHookFile struct { - Hooks map[string][]gooseMatcherGroup `json:"hooks"` -} - -type gooseMatcherGroup struct { - Matcher *string `json:"matcher,omitempty"` - Hooks []gooseHookEntry `json:"hooks"` -} - -type gooseHookEntry struct { - Type string `json:"type"` - Command string `json:"command"` - Timeout int `json:"timeout,omitempty"` -} - -// gooseHookSpec describes one hook AO installs, defined in code rather than read -// from an embedded hooks file. -type gooseHookSpec struct { - Event string - Command string -} - // gooseManagedHooks is the source of truth for the hooks AO installs. Goose // groups every hook under the nil matcher. Goose has no permission/approval // lifecycle event yet, so AO installs only the session/prompt/stop signals. -var gooseManagedHooks = []gooseHookSpec{ +var gooseManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Command: gooseHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: gooseHookCommandPrefix + "user-prompt-submit"}, {Event: "Stop", Command: gooseHookCommandPrefix + "stop"}, } -// GetAgentHooks installs AO's Goose hooks into the worktree-local -// .agents/plugins/ao/hooks/hooks.json file. Existing hook entries are preserved -// and duplicate AO commands are not appended. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(cfg.WorkspacePath) == "" { - return errors.New("goose.GetAgentHooks: WorkspacePath is required") - } - - hooksPath := gooseHooksPath(cfg.WorkspacePath) - topLevel, rawHooks, err := readGooseHooks(hooksPath) - if err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - - for event, specs := range groupGooseHooksByEvent() { - var existingGroups []gooseMatcherGroup - if err := parseGooseHookType(rawHooks, event, &existingGroups); err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - for _, spec := range specs { - if !gooseHookCommandExists(existingGroups, spec.Command) { - entry := gooseHookEntry{Type: "command", Command: spec.Command, Timeout: gooseHookTimeout} - existingGroups = addGooseHook(existingGroups, entry) - } - } - if err := marshalGooseHookType(rawHooks, event, existingGroups); err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - } - - if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), gooseHooksFileName); err != nil { - return fmt.Errorf("goose.GetAgentHooks: gitignore: %w", err) - } - return nil -} - -// UninstallHooks removes AO's Goose hooks from the workspace-local -// .agents/plugins/ao/hooks/hooks.json file, leaving user-defined hooks -// untouched. A missing file is a no-op. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(workspacePath) == "" { - return errors.New("goose.UninstallHooks: workspacePath is required") - } - - hooksPath := gooseHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return nil - } - topLevel, rawHooks, err := readGooseHooks(hooksPath) - if err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - - for _, event := range gooseManagedEvents() { - var groups []gooseMatcherGroup - if err := parseGooseHookType(rawHooks, event, &groups); err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - groups = removeGooseManagedHooks(groups) - if err := marshalGooseHookType(rawHooks, event, groups); err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - } - - if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - return nil -} - -// AreHooksInstalled reports whether any AO Goose hook is present in the -// workspace-local hooks file. A missing file means none are installed. -func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - if strings.TrimSpace(workspacePath) == "" { - return false, errors.New("goose.AreHooksInstalled: workspacePath is required") - } - - hooksPath := gooseHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return false, nil - } - _, rawHooks, err := readGooseHooks(hooksPath) - if err != nil { - return false, fmt.Errorf("goose.AreHooksInstalled: %w", err) - } - - for _, event := range gooseManagedEvents() { - var groups []gooseMatcherGroup - if err := parseGooseHookType(rawHooks, event, &groups); err != nil { - return false, fmt.Errorf("goose.AreHooksInstalled: %w", err) - } - for _, group := range groups { - for _, hook := range group.Hooks { - if isGooseManagedHook(hook.Command) { - return true, nil - } - } - } - } - return false, nil +// gooseHooks manages AO's hooks in the workspace-local +// .agents/plugins/ao/hooks/hooks.json file. +var gooseHooks = hooksjson.Manager{ + Label: "goose", + CommandPrefix: gooseHookCommandPrefix, + Timeout: gooseHookTimeout, + Path: gooseHooksPath, + Managed: gooseManagedHooks, } func gooseHooksPath(workspacePath string) string { return filepath.Join(workspacePath, gooseHooksRootDirName, goosePluginsDirName, goosePluginName, gooseHooksSubDirName, gooseHooksFileName) } -// readGooseHooks loads the hooks file into a top-level raw map plus the decoded -// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty file -// yields empty maps. -func readGooseHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { - topLevel = map[string]json.RawMessage{} - rawHooks = map[string]json.RawMessage{} - - data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir - if errors.Is(err, os.ErrNotExist) { - return topLevel, rawHooks, nil - } - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) - } - if strings.TrimSpace(string(data)) == "" { - return topLevel, rawHooks, nil - } - if err := json.Unmarshal(data, &topLevel); err != nil { - return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) - } - if hooksRaw, ok := topLevel["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) - } - } - return topLevel, rawHooks, nil +// GetAgentHooks installs AO's Goose hooks, preserving user-defined hooks. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return gooseHooks.Install(ctx, cfg.WorkspacePath) } -// writeGooseHooks folds rawHooks back into topLevel and writes the file. An -// empty hooks map drops the "hooks" key entirely. -func writeGooseHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { - if len(rawHooks) == 0 { - delete(topLevel, "hooks") - } else { - hooksJSON, err := json.Marshal(rawHooks) - if err != nil { - return fmt.Errorf("encode hooks: %w", err) - } - topLevel["hooks"] = hooksJSON - } - - if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { - return fmt.Errorf("create hook dir: %w", err) - } - data, err := json.MarshalIndent(topLevel, "", " ") - if err != nil { - return fmt.Errorf("encode %s: %w", hooksPath, err) - } - data = append(data, '\n') - if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { - return fmt.Errorf("write %s: %w", hooksPath, err) - } - return nil +// UninstallHooks removes AO's Goose hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return gooseHooks.Uninstall(ctx, workspacePath) } -// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- -// write can't leave a truncated/empty file that Goose then fails to parse. -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} - -// groupGooseHooksByEvent groups the managed hook specs by their Goose event so -// each event's array is rewritten once. -func groupGooseHooksByEvent() map[string][]gooseHookSpec { - byEvent := map[string][]gooseHookSpec{} - for _, spec := range gooseManagedHooks { - byEvent[spec.Event] = append(byEvent[spec.Event], spec) - } - return byEvent -} - -// gooseManagedEvents returns the distinct Goose events AO manages, in the order -// they first appear in gooseManagedHooks. -func gooseManagedEvents() []string { - seen := map[string]bool{} - events := make([]string, 0, len(gooseManagedHooks)) - for _, spec := range gooseManagedHooks { - if !seen[spec.Event] { - seen[spec.Event] = true - events = append(events, spec.Event) - } - } - return events -} - -func isGooseManagedHook(command string) bool { - return strings.HasPrefix(command, gooseHookCommandPrefix) -} - -// removeGooseManagedHooks strips AO hook entries from every group, dropping any -// group left without hooks. -func removeGooseManagedHooks(groups []gooseMatcherGroup) []gooseMatcherGroup { - result := make([]gooseMatcherGroup, 0, len(groups)) - for _, group := range groups { - kept := make([]gooseHookEntry, 0, len(group.Hooks)) - for _, hook := range group.Hooks { - if !isGooseManagedHook(hook.Command) { - kept = append(kept, hook) - } - } - if len(kept) > 0 { - group.Hooks = kept - result = append(result, group) - } - } - return result -} - -func parseGooseHookType(rawHooks map[string]json.RawMessage, event string, target *[]gooseMatcherGroup) error { - data, ok := rawHooks[event] - if !ok { - return nil - } - if err := json.Unmarshal(data, target); err != nil { - return fmt.Errorf("parse %s hooks: %w", event, err) - } - return nil -} - -func marshalGooseHookType(rawHooks map[string]json.RawMessage, event string, groups []gooseMatcherGroup) error { - if len(groups) == 0 { - delete(rawHooks, event) - return nil - } - data, err := json.Marshal(groups) - if err != nil { - return fmt.Errorf("encode %s hooks: %w", event, err) - } - rawHooks[event] = data - return nil -} - -func gooseHookCommandExists(groups []gooseMatcherGroup, command string) bool { - for _, group := range groups { - for _, hook := range group.Hooks { - if hook.Command == command { - return true - } - } - } - return false -} - -func addGooseHook(groups []gooseMatcherGroup, hook gooseHookEntry) []gooseMatcherGroup { - for i, group := range groups { - if group.Matcher == nil { - groups[i].Hooks = append(groups[i].Hooks, hook) - return groups - } - } - return append(groups, gooseMatcherGroup{ - Matcher: nil, - Hooks: []gooseHookEntry{hook}, - }) +// AreHooksInstalled reports whether any AO Goose hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return gooseHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/grok/grok.go b/backend/internal/adapters/agent/grok/grok.go index ddebb78a0..0f5ca4cfb 100644 --- a/backend/internal/adapters/agent/grok/grok.go +++ b/backend/internal/adapters/agent/grok/grok.go @@ -16,21 +16,32 @@ package grok import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) +var grokBinarySpec = binaryutil.BinarySpec{ + Label: "grok", + Names: []string{"grok"}, + WinNames: []string{"grok.cmd", "grok.exe", "grok"}, + UnixPaths: []string{"/usr/local/bin/grok", "/opt/homebrew/bin/grok"}, + UnixHomePaths: [][]string{{".grok", "bin", "grok"}, {".local", "bin", "grok"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "grok.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "grok.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".grok", "bin", "grok.exe"}}, + }, +} + // Plugin is the Grok Build agent adapter. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -56,14 +67,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds `grok --no-auto-update [--permission-mode ] -p `. // Prompt is delivered via -p (in command). // @@ -85,14 +88,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetAgentHooks reuses the Claude Code hook installer because Grok Build // has a full Claude Code compatibility layer. // @@ -166,81 +161,13 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - // The keys written by claude hooks (which we install for grok too). - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveGrokBinary finds the `grok` binary (xAI Grok Build CLI). func ResolveGrokBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"grok.cmd", "grok.exe", "grok"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "grok.cmd"), - filepath.Join(appData, "npm", "grok.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".grok", "bin", "grok.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("grok: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("grok"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/grok", - "/opt/homebrew/bin/grok", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".grok", "bin", "grok"), - filepath.Join(home, ".local", "bin", "grok"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("grok: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, grokBinarySpec) } func (p *Plugin) grokBinary(ctx context.Context) (string, error) { @@ -260,7 +187,7 @@ func (p *Plugin) grokBinary(ctx context.Context) (string, error) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's ~/.grok/config.toml (or default behavior). case ports.PermissionModeAcceptEdits: @@ -271,20 +198,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--permission-mode", "bypassPermissions") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/hooksjson/hooksjson.go b/backend/internal/adapters/agent/hooksjson/hooksjson.go new file mode 100644 index 000000000..21d497b54 --- /dev/null +++ b/backend/internal/adapters/agent/hooksjson/hooksjson.go @@ -0,0 +1,332 @@ +// Package hooksjson implements the matcher-group hooks file that several agents +// (claude-code, goose, qwen, agy, droid) share byte-for-byte in shape. Each such +// file is a JSON object with a "hooks" sub-map keyed by native event name, whose +// values are matcher groups ({matcher?, hooks:[{type,command,timeout}]}). The +// adapters differed only in the file path, the AO command prefix, the per-hook +// timeout, and which events they install, so they describe those with a Manager +// and share the install/uninstall/detect logic here. +// +// The read/write path preserves every top-level key and every user-defined hook +// AO does not own, and writes atomically, so installing AO's hooks never clobbers +// unrelated settings. +package hooksjson + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" +) + +// HookEntry is one command hook inside a matcher group. +type HookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// MatcherGroup is a set of hooks sharing one matcher. Matcher is a pointer so it +// round-trips exactly: events that require a matcher (e.g. claude SessionStart's +// "startup") carry one; events that omit it serialize without the key. +type MatcherGroup struct { + Matcher *string `json:"matcher,omitempty"` + Hooks []HookEntry `json:"hooks"` +} + +// HookSpec describes one hook AO installs: the native event it attaches to, its +// optional matcher, and the command to run. Adapters define these in code rather +// than reading an embedded template. +type HookSpec struct { + Event string + Matcher *string + Command string +} + +// Manager installs, removes, and detects AO's hooks in one agent's matcher-group +// hooks file. Construct one per adapter with its file path, command prefix, +// per-hook timeout, and managed hook set. +type Manager struct { + // Label prefixes error messages, e.g. "claude-code" or "goose", so the + // wrapped error reads "
-
+
{view === "summary" ? : null} {view === "reviews" ? : null} {view === "browser" ? ( diff --git a/frontend/src/renderer/styles.css b/frontend/src/renderer/styles.css index eb419dde5..6bffd91d2 100644 --- a/frontend/src/renderer/styles.css +++ b/frontend/src/renderer/styles.css @@ -773,6 +773,18 @@ body.is-resizing-x [data-slot="sidebar-container"] { padding: 18px 18px 40px; } +/* Browser tab: the panel fills the rail edge-to-edge, so drop the body padding + and the panel's own border/radius so it sits flush against the resize handle. */ +.session-inspector__body.session-inspector__body--browser { + padding: 0; + overflow: hidden; +} + +.session-inspector__body--browser .browser-panel { + border: 0; + border-radius: 0; +} + /* Inspector resize handle (shadcn resizable separator, AO visual): a 7px * transparent grab strip whose centered 1px line lights accent on hover, drag * (rrp sets data-separator="active"), and keyboard focus. */