diff --git a/CHANGELOG.md b/CHANGELOG.md index af4f6fa..b39e2df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.11] - 2026-07-23 + +### Added + +- **Buzz orchestrator**: Detection for Block's Buzz (`AgentTypeBuzz`), a chat/agent platform whose `buzz-acp` harness spawns Claude Code / Codex / Goose over ACP. Detects via `ORCHESTRATOR_ENV=buzz` (generic hatch), `BUZZ_ACP_AGENT_COMMAND` (best-effort env secondary), or — the reliable, config-independent signal — the `buzz-acp` process appearing in the ancestry chain. Deliberately does not sniff the workstation-scoped `BUZZ_RELAY_URL` / `BUZZ_PRIVATE_KEY`, which would false-tag a plain agent session in a Buzz user's shell. +- **`Environment.ProcessAncestry()`**: New interface method returning ancestor process executable base names (parent → PID 1). Unix walks a single `ps` snapshot in memory (no new dependency, works on macOS/BSD/Linux); Windows is a stub (falls back to env-var signals), matching ox's own `internal/proc` approach. Enables detecting orchestrators that spawn agents without setting a marker env var. + ## [0.1.10] - 2026-05-08 ### Added diff --git a/agent.go b/agent.go index d12508e..bc7cf07 100644 --- a/agent.go +++ b/agent.go @@ -131,6 +131,7 @@ const ( AgentTypeOpenClaw AgentType = "openclaw" AgentTypeConductor AgentType = "conductor" AgentTypeGasCity AgentType = "gascity" + AgentTypeBuzz AgentType = "buzz" ) // SupportedAgents is the canonical list of coding agents and orchestrators @@ -159,6 +160,7 @@ var SupportedAgents = []AgentType{ AgentTypeOpenClaw, AgentTypeConductor, AgentTypeGasCity, + AgentTypeBuzz, } // AgentIdentity provides basic agent identification. diff --git a/agent_test.go b/agent_test.go index a9bd17d..42f65f3 100644 --- a/agent_test.go +++ b/agent_test.go @@ -31,6 +31,7 @@ func TestSupportedAgents(t *testing.T) { AgentTypeOpenClaw, AgentTypeConductor, AgentTypeGasCity, + AgentTypeBuzz, } assert.Equal(t, len(expected), len(SupportedAgents), "SupportedAgents count mismatch") diff --git a/environment.go b/environment.go index 224d9b7..d7531ea 100644 --- a/environment.go +++ b/environment.go @@ -62,6 +62,15 @@ type Environment interface { // Stat returns file info. Stat(path string) (os.FileInfo, error) + + // ProcessAncestry returns the executable base names of ancestor processes, + // starting from the current process's parent and walking toward PID 1. + // It exists so detectors can identify an orchestrator that spawns agents + // without setting a marker env var (e.g. Buzz's buzz-acp harness, which is + // always an ancestor of the agent it launches). Best-effort: returns an + // empty slice, not an error, on platforms or hosts where the process tree + // can't be read, so callers can cleanly fall back to env-var signals. + ProcessAncestry() ([]string, error) } // SystemEnvironment implements Environment using the real system. @@ -203,6 +212,11 @@ func (e *SystemEnvironment) Stat(path string) (os.FileInfo, error) { return os.Stat(path) } +func (e *SystemEnvironment) ProcessAncestry() ([]string, error) { + // Delegates to a build-tagged helper (unix uses `ps`, windows is a stub). + return processAncestry() +} + // MockEnvironment is a test implementation of Environment. type MockEnvironment struct { EnvVars map[string]string @@ -225,6 +239,8 @@ type MockEnvironment struct { RemoveErrors map[string]error // inject remove errors by path DirEntries map[string][]os.DirEntry // mock ReadDir results StatErrors map[string]error // inject stat errors by path + Ancestry []string // mock ProcessAncestry result (parent-first) + AncestryError error // inject a ProcessAncestry error } // NewMockEnvironment creates a mock environment for testing. @@ -404,3 +420,7 @@ func (e *MockEnvironment) Stat(path string) (os.FileInfo, error) { } return nil, os.ErrNotExist } + +func (e *MockEnvironment) ProcessAncestry() ([]string, error) { + return e.Ancestry, e.AncestryError +} diff --git a/environment_ancestry_unix.go b/environment_ancestry_unix.go new file mode 100644 index 0000000..e95aa3b --- /dev/null +++ b/environment_ancestry_unix.go @@ -0,0 +1,79 @@ +//go:build !windows + +package agentx + +import ( + "os" + "os/exec" + "strconv" + "strings" +) + +// processAncestry returns the executable base names of ancestor processes, +// starting from the current process's parent and walking toward PID 1. +// +// It takes a single `ps` snapshot (pid, ppid, comm) and walks the parent map in +// memory rather than exec-ing once per level, and it does not read /proc — so it +// works on macOS and the BSDs as well as Linux. This mirrors the approach in +// ox's internal/proc and is deliberately dependency-free (no golang.org/x/sys). +// +// Best-effort by contract: any failure returns (nil, err) and the caller falls +// back to env-var signals. +func processAncestry() ([]string, error) { + out, err := exec.Command("ps", "-A", "-o", "pid=,ppid=,comm=").Output() + if err != nil { + return nil, err + } + return walkAncestry(string(out), os.Getppid()), nil +} + +// walkAncestry parses `ps -A -o pid=,ppid=,comm=` output into a pid → parent map +// and returns the executable base names from startPPID up toward PID 1, capped at +// 20 levels. Extracted from processAncestry as a pure, deterministic function so +// the parsing and tree-walking logic is unit-testable without a real `ps`. +func walkAncestry(psOutput string, startPPID int) []string { + type parent struct { + ppid int + name string + } + procs := make(map[int]parent) + for _, line := range strings.Split(psOutput, "\n") { + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err != nil { + continue + } + ppid, err := strconv.Atoi(fields[1]) + if err != nil { + continue + } + // comm can contain spaces (a full path on macOS); rejoin the remaining + // fields, then reduce to the base name. + name := strings.Join(fields[2:], " ") + if idx := strings.LastIndex(name, "/"); idx >= 0 { + name = name[idx+1:] + } + procs[pid] = parent{ppid: ppid, name: name} + } + + var chain []string + pid := startPPID + for range 20 { + if pid <= 1 { + break + } + p, ok := procs[pid] + if !ok { + break + } + chain = append(chain, p.name) + if p.ppid == pid { // defensive: never loop on a self-parent + break + } + pid = p.ppid + } + return chain +} diff --git a/environment_ancestry_unix_test.go b/environment_ancestry_unix_test.go new file mode 100644 index 0000000..d0c24f4 --- /dev/null +++ b/environment_ancestry_unix_test.go @@ -0,0 +1,132 @@ +//go:build !windows + +package agentx + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWalkAncestry(t *testing.T) { + // A realistic `ps -A -o pid=,ppid=,comm=` snapshot: an ox process launched + // under Claude, which was spawned by buzz-acp, under a login shell. + snapshot := strings.Join([]string{ + " 1 0 launchd", + " 200 1 zsh", + " 300 200 buzz-acp", + " 400 300 claude-agent-acp", + " 500 400 /opt/homebrew/bin/ox", // full path -> base name + " 600 500 ps", + }, "\n") + + tests := []struct { + name string + psOutput string + startPPID int + want []string + why string + }{ + { + name: "full chain, PID 1 excluded, path base-named", + psOutput: snapshot, + startPPID: 500, + want: []string{"ox", "claude-agent-acp", "buzz-acp", "zsh"}, + why: "walk climbs toward init but stops before PID 1 (never an orchestrator); a full path is reduced to its base name", + }, + { + name: "start mid-chain", + psOutput: snapshot, + startPPID: 300, + want: []string{"buzz-acp", "zsh"}, + why: "walk begins at startPPID, not the leaf, and still excludes PID 1", + }, + { + name: "unknown start pid yields empty", + psOutput: snapshot, + startPPID: 9999, + want: nil, + why: "a pid absent from the snapshot must not fabricate a chain", + }, + { + name: "empty output", + psOutput: "", + startPPID: 500, + want: nil, + why: "no ps data must yield an empty chain, not a panic", + }, + { + name: "malformed lines are skipped", + psOutput: strings.Join([]string{ + "garbage header line", + " abc 200 notanumber-pid", // non-numeric pid -> skip + " 300 xyz bad-ppid", // non-numeric ppid -> skip + " 200 1 zsh", + " 300 200 buzz-acp", + }, "\n"), + startPPID: 300, + want: []string{"buzz-acp", "zsh"}, + why: "unparseable rows must be ignored, valid rows still walked", + }, + { + name: "comm containing spaces is preserved then base-named", + psOutput: strings.Join([]string{ + " 200 1 zsh", + " 300 200 /Applications/My App.app/Contents/MacOS/My App", + }, "\n"), + startPPID: 300, + want: []string{"My App", "zsh"}, + why: "comm may contain spaces; base name is everything after the last slash", + }, + { + name: "self-parent does not infinite-loop", + psOutput: strings.Join([]string{ + " 300 300 stuck", // ppid == pid + }, "\n"), + startPPID: 300, + want: []string{"stuck"}, + why: "a self-referential parent must terminate the walk after one hop", + }, + { + name: "walk is capped at 20 levels", + psOutput: deepChain(30), + startPPID: 30, + want: cappedNames(30, 20), + why: "an unexpectedly deep tree must not walk unbounded", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := walkAncestry(tt.psOutput, tt.startPPID) + assert.Equal(t, tt.want, got, tt.why) + }) + } +} + +// deepChain builds a linear ps snapshot of n processes: pid k has ppid k-1, +// name "p", with p1's parent being PID 1's sentinel (0). +func deepChain(n int) string { + var b strings.Builder + for k := 1; k <= n; k++ { + fmt.Fprintf(&b, "%d %d p%d\n", k, k-1, k) + } + return b.String() +} + +// cappedNames returns the names the walk should collect starting at `start` and +// climbing (start, start-1, ...) for at most `cap` levels, stopping at pid<=1. +func cappedNames(start, cap int) []string { + var names []string + pid := start + for range cap { + if pid <= 1 { + break + } + names = append(names, fmt.Sprintf("p%d", pid)) + pid-- + } + return names +} diff --git a/environment_ancestry_windows.go b/environment_ancestry_windows.go new file mode 100644 index 0000000..fd04c8b --- /dev/null +++ b/environment_ancestry_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package agentx + +// processAncestry is a stub on Windows. Walking the process tree there needs the +// Toolhelp32 snapshot API (golang.org/x/sys/windows), and agentx is intentionally +// dependency-free; ox's own internal/proc stubs Windows ancestry for the same +// reason. Buzz's buzz-acp harness is a macOS/Linux dev tool today, so on Windows +// BuzzAgent.Detect() cleanly falls back to its ORCHESTRATOR_ENV / +// BUZZ_ACP_AGENT_COMMAND env-var signals. Implement via Toolhelp32 if/when +// Buzz-on-Windows detection is needed. +func processAncestry() ([]string, error) { + return nil, nil +} diff --git a/orchestrators/buzz.go b/orchestrators/buzz.go new file mode 100644 index 0000000..681f676 --- /dev/null +++ b/orchestrators/buzz.go @@ -0,0 +1,116 @@ +package orchestrators + +import ( + "context" + "path/filepath" + "strings" + + "github.com/sageox/agentx" +) + +// BuzzAgent detects Block's Buzz (https://github.com/block/buzz), a chat/agent +// platform whose buzz-acp harness spawns Claude Code / Codex / Goose as +// sub-agents over ACP. Buzz sets no fixed "spawned by Buzz" marker on the child, +// so detection leans on process ancestry (buzz-acp is always an ancestor) with +// env-var signals as cheaper first checks. +type BuzzAgent struct{} + +func NewBuzzAgent() *BuzzAgent { return &BuzzAgent{} } + +func (a *BuzzAgent) Type() agentx.AgentType { return agentx.AgentTypeBuzz } +func (a *BuzzAgent) Name() string { return "Buzz" } +func (a *BuzzAgent) URL() string { return "https://github.com/block/buzz" } +func (a *BuzzAgent) Role() agentx.AgentRole { return agentx.RoleOrchestrator } + +func (a *BuzzAgent) Detect(_ context.Context, env agentx.Environment) (bool, error) { + // Generic self-identification hatch. Not emitted by Buzz itself today, but an + // operator or wrapper can set it, and it stays consistent with the other + // orchestrators (Conductor/Gas City/OpenClaw). + if env.GetEnv("ORCHESTRATOR_ENV") == "buzz" { + return true, nil + } + + // Best-effort env secondary: buzz-acp's own harness config var. Because + // buzz-acp never clears the environment, a child inherits it when the + // operator configured buzz-acp via the env var (rather than the + // --agent-command flag). + if env.GetEnv("BUZZ_ACP_AGENT_COMMAND") != "" { + return true, nil + } + + // Reliable, config-independent signal: buzz-acp is always an ancestor of the + // agent process it launches, so it appears in the ancestry chain regardless + // of how the operator configured Buzz. This is the reason ProcessAncestry + // was added to the Environment interface. + if ancestry, err := env.ProcessAncestry(); err == nil { + for _, name := range ancestry { + if strings.HasPrefix(name, "buzz-acp") { + return true, nil + } + } + } + + // Deliberately NOT sniffing BUZZ_RELAY_URL / BUZZ_PRIVATE_KEY: those are + // workstation-scoped Quick-Start exports users keep in their shell profile, + // so they would false-positive on any plain agent session in a Buzz user's + // shell — mis-answering the discriminating question "did Buzz launch THIS + // process?". + return false, nil +} + +func (a *BuzzAgent) UserConfigPath(env agentx.Environment) (string, error) { + // Best-effort; nothing in agentx writes here (managers are nil). Buzz is + // configured via CLI flags / env / relay and installs persona packs out of + // band, so there is no canonical per-user config dir to point at. + home, err := env.HomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".buzz"), nil +} + +// Buzz's "project" is a relay-hosted chat channel/workspace, not a git-adjacent +// directory, so there is no project-level config path (matches Gas City/OpenClaw). +func (a *BuzzAgent) ProjectConfigPath() string { return "" } + +// Project-root context file the underlying coding agent may read; also ox's +// universal injection target. +func (a *BuzzAgent) ContextFiles() []string { return []string{"AGENTS.md"} } +func (a *BuzzAgent) SupportsXDGConfig() bool { return false } + +func (a *BuzzAgent) Capabilities() agentx.Capabilities { + // MCPServers is load-bearing: buzz-acp forwards persona-pack MCP servers to + // the child agent over the ACP session/new request (protocol-level, identical + // across Claude Code / Codex / Goose). That is Buzz's durable integration + // surface. + return agentx.Capabilities{MCPServers: true, SystemPrompt: true, ProjectContext: true} +} + +// ox does not manage Buzz-native hooks/commands/rules — pure orchestrator. +func (a *BuzzAgent) HookManager() agentx.HookManager { return nil } +func (a *BuzzAgent) CommandManager() agentx.CommandManager { return nil } +func (a *BuzzAgent) RulesManager() agentx.RulesManager { return nil } + +func (a *BuzzAgent) DetectVersion(_ context.Context, env agentx.Environment) string { + // Best-effort; `buzz` is frequently absent from a spawned agent's PATH, so + // this usually returns "" in the child, which is harmless. + return versionFromCommand(env, "buzz", "--version") +} + +func (a *BuzzAgent) IsInstalled(_ context.Context, env agentx.Environment) (bool, error) { + if _, err := env.LookPath("buzz"); err == nil { + return true, nil + } + if _, err := env.LookPath("buzz-acp"); err == nil { + return true, nil + } + return false, nil +} + +// buzz-acp assigns an ACP session id internally but exposes no per-session id +// env var to the child today. Flip to true and read that var if/when Buzz +// exports one (cf. Gas City's GC_RUN_ID). +func (a *BuzzAgent) SupportsSession() bool { return false } +func (a *BuzzAgent) SessionID(_ agentx.Environment) string { return "" } + +var _ agentx.Agent = (*BuzzAgent)(nil) diff --git a/orchestrators/buzz_test.go b/orchestrators/buzz_test.go new file mode 100644 index 0000000..7f207c4 --- /dev/null +++ b/orchestrators/buzz_test.go @@ -0,0 +1,187 @@ +package orchestrators + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/sageox/agentx" +) + +func TestBuzzAgent_Detect(t *testing.T) { + tests := []struct { + name string + env map[string]string + ancestry []string + ancestryErr bool + want bool + why string + }{ + { + name: "ORCHESTRATOR_ENV=buzz", + env: map[string]string{"ORCHESTRATOR_ENV": "buzz"}, + want: true, + why: "generic self-ID hatch must detect", + }, + { + name: "BUZZ_ACP_AGENT_COMMAND set", + env: map[string]string{"BUZZ_ACP_AGENT_COMMAND": "claude-agent-acp"}, + want: true, + why: "buzz-acp harness config var (env-form) is a valid secondary signal", + }, + { + name: "buzz-acp direct parent", + ancestry: []string{"claude-agent-acp", "buzz-acp"}, + want: true, + why: "the reliable spawn-scoped signal must fire with no env vars set", + }, + { + name: "buzz-acp deeper in ancestry chain", + ancestry: []string{"bash", "claude", "buzz-acp", "login"}, + want: true, + why: "detection must walk past intermediate shells/agents", + }, + { + name: "buzz-acp-worker prefix", + ancestry: []string{"buzz-acp-worker"}, + want: true, + why: "HasPrefix must tolerate suffixed/truncated harness names", + }, + { + name: "BUZZ_RELAY_URL alone does NOT detect", + env: map[string]string{"BUZZ_RELAY_URL": "ws://localhost:3000"}, + want: false, + why: "workstation-scoped var must never false-tag a plain agent session (guards against re-adding the rejected heuristic)", + }, + { + name: "BUZZ_PRIVATE_KEY alone does NOT detect", + env: map[string]string{"BUZZ_PRIVATE_KEY": "nsec1abc"}, + want: false, + why: "workstation-scoped var must never false-tag a plain agent session", + }, + { + name: "bare buzz in ancestry does NOT detect", + ancestry: []string{"buzz", "bash"}, + want: false, + why: "the Buzz chat CLI is not the buzz-acp spawner; only buzz-acp counts", + }, + { + name: "wrong ORCHESTRATOR_ENV", + env: map[string]string{"ORCHESTRATOR_ENV": "conductor"}, + want: false, + why: "another orchestrator's declaration must not be claimed as Buzz", + }, + { + name: "unrelated ancestry", + ancestry: []string{"claude", "bash", "zsh"}, + want: false, + why: "a normal Claude Code terminal session must not detect as Buzz", + }, + { + name: "no signals", + want: false, + why: "clean environment is the negative baseline", + }, + { + name: "ORCHESTRATOR_ENV case-sensitive", + env: map[string]string{"ORCHESTRATOR_ENV": "Buzz"}, + want: false, + why: "match is exact (no case-fold), mirroring the other orchestrators", + }, + { + name: "ancestry probe error falls through cleanly", + ancestryErr: true, + want: false, + why: "a ProcessAncestry error must not panic or false-positive; detection just yields false", + }, + { + name: "env signal wins even when ancestry probe errors", + env: map[string]string{"ORCHESTRATOR_ENV": "buzz"}, + ancestryErr: true, + want: true, + why: "cheap env checks run before ancestry, so a probe failure never masks an explicit signal", + }, + { + name: "buzz-acp ancestry detects despite a conflicting ORCHESTRATOR_ENV", + env: map[string]string{"ORCHESTRATOR_ENV": "conductor"}, + ancestry: []string{"claude-agent-acp", "buzz-acp"}, + want: true, + why: "ancestry is ground truth: buzz-acp literally launched us, so a stale/wrong env label must not hide it", + }, + } + + agent := NewBuzzAgent() + ctx := context.Background() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := agentx.NewMockEnvironment(tt.env) + env.Ancestry = tt.ancestry + if tt.ancestryErr { + env.AncestryError = errors.New("ps snapshot failed") + } + got, err := agent.Detect(ctx, env) + assert.NoError(t, err) + assert.Equal(t, tt.want, got, tt.why) + }) + } +} + +func TestBuzzAgent_Identity(t *testing.T) { + agent := NewBuzzAgent() + assert.Equal(t, agentx.AgentTypeBuzz, agent.Type()) + assert.Equal(t, "Buzz", agent.Name()) + assert.Equal(t, agentx.RoleOrchestrator, agent.Role()) + assert.Equal(t, "https://github.com/block/buzz", agent.URL()) +} + +func TestBuzzAgent_PureOrchestrator(t *testing.T) { + agent := NewBuzzAgent() + // Buzz manages no ox-native hooks/commands/rules and exposes no session id. + assert.Nil(t, agent.HookManager()) + assert.Nil(t, agent.CommandManager()) + assert.Nil(t, agent.RulesManager()) + assert.False(t, agent.SupportsSession()) + assert.Equal(t, "", agent.ProjectConfigPath()) +} + +func TestBuzzAgent_Config(t *testing.T) { + agent := NewBuzzAgent() + ctx := context.Background() + + // Capabilities: MCP is the load-bearing one (persona-pack MCP forwarding). + caps := agent.Capabilities() + assert.True(t, caps.MCPServers, "MCPServers must be true — Buzz's durable integration surface") + assert.True(t, caps.SystemPrompt) + assert.True(t, caps.ProjectContext) + assert.False(t, caps.Hooks, "ox installs no Buzz-native hooks") + + assert.Equal(t, []string{"AGENTS.md"}, agent.ContextFiles()) + assert.False(t, agent.SupportsXDGConfig()) + + // UserConfigPath is a best-effort ~/.buzz. + env := agentx.NewMockEnvironment(nil) // Home defaults to /home/test + p, err := agent.UserConfigPath(env) + assert.NoError(t, err) + assert.Equal(t, "/home/test/.buzz", p) + + // IsInstalled: either `buzz` or `buzz-acp` on PATH counts. + assert.NoError(t, err) + notInstalled, err := agent.IsInstalled(ctx, agentx.NewMockEnvironment(nil)) + assert.NoError(t, err) + assert.False(t, notInstalled, "no binary on PATH -> not installed") + + withHarness := agentx.NewMockEnvironment(nil) + withHarness.PathBinaries = map[string]string{"buzz-acp": "/usr/local/bin/buzz-acp"} + installed, err := agent.IsInstalled(ctx, withHarness) + assert.NoError(t, err) + assert.True(t, installed, "buzz-acp on PATH -> installed") + + // DetectVersion parses `buzz --version` output; absent binary -> "". + verEnv := agentx.NewMockEnvironment(nil) + verEnv.ExecOutputs = map[string][]byte{"buzz": []byte("buzz version 1.2.3\n")} + assert.Equal(t, "1.2.3", agent.DetectVersion(ctx, verEnv)) + assert.Equal(t, "", agent.DetectVersion(ctx, agentx.NewMockEnvironment(nil))) +} diff --git a/setup/setup.go b/setup/setup.go index a45f049..ecc794f 100644 --- a/setup/setup.go +++ b/setup/setup.go @@ -95,4 +95,5 @@ func RegisterDefaultAgents() { agentx.DefaultRegistry.Register(orchestrators.NewOpenClawAgent()) agentx.DefaultRegistry.Register(orchestrators.NewConductorAgent()) agentx.DefaultRegistry.Register(orchestrators.NewGasCityAgent()) + agentx.DefaultRegistry.Register(orchestrators.NewBuzzAgent()) }