Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: "1.22"
# Read the version from go.mod so CI can't drift from the module
# (it previously pinned 1.22 while go.mod declared 1.25).
go-version-file: backend/go.mod
cache: false

- name: Check formatting
Expand All @@ -42,3 +44,27 @@ jobs:

- name: Test
run: go test -race ./...

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
cache: false

- name: golangci-lint
# v8 of the action drives golangci-lint v2 (the schema this config uses);
# the v6 action speaks v1 CLI flags and errors against a v2 binary.
uses: golangci/golangci-lint-action@v8
with:
# Pinned for reproducibility: bump intentionally rather than letting an
# upstream release change CI. Must be built with Go >= the module's
# (go.mod is 1.25); v2.12.2 is built with go1.25 — older v2 tags
# (e.g. v2.1.x) are built with go1.24 and refuse to analyze 1.25 code.
version: v2.12.2
working-directory: backend
# Blocking on the full ruleset: the tree is clean at zero findings, so
# any new issue fails CI rather than being grandfathered.
115 changes: 115 additions & 0 deletions backend/.golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# golangci-lint v2 config for the AO backend.
# Run: golangci-lint run ./... (from backend/)
version: "2"

run:
timeout: 5m

issues:
# Report every finding, not the default first-50-per-linter / 3-same.
max-issues-per-linter: 0
max-same-issues: 0

linters:
default: none
enable:
# --- correctness ---
- errcheck # unchecked errors
- govet # suspicious constructs
- ineffassign # ineffectual assignments
- staticcheck # the big static analyzer
- unused # dead code (funcs/vars/types/fields)
- errorlint # error wrapping / comparison bugs
- bodyclose # unclosed HTTP response bodies
- sqlclosecheck # unclosed sql.Rows/Stmt
- rowserrcheck # missing rows.Err()
- nilerr # `return nil` after a non-nil err check
- makezero # append to a non-zero-len make() slice
- gocheckcompilerdirectives # malformed //go: directives
- reassign # reassigning package-level vars from other pkgs
# --- dead code / boilerplate (the "nuke" linters) ---
- unparam # unused function params / always-same returns
- unconvert # unnecessary type conversions
- wastedassign # assignments never read
- copyloopvar # redundant loop-var copies (Go 1.22+)
- prealloc # slices that could be preallocated
- dupl # copy-pasted code blocks
# --- style / quality ---
- revive # configurable golint successor
- gocritic # opinionated diagnostics + style
- misspell # typos in comments/strings
- usestdlibvars # use stdlib consts (http.MethodGet, etc.)
- predeclared # shadowing predeclared identifiers
- nakedret # naked returns in long funcs
# --- security ---
- gosec

settings:
errcheck:
check-type-assertions: true
govet:
enable-all: true
disable:
- fieldalignment # struct field ordering is not worth the churn
- shadow # shadowing `err` in nested scopes is idiomatic Go
revive:
rules:
- { name: exported } # doc comments on every exported symbol
- { name: blank-imports }
- { name: context-as-argument }
- { name: context-keys-type }
- { name: dot-imports }
- { name: error-return }
- { name: error-strings }
- { name: error-naming }
- { name: indent-error-flow }
- { name: errorf }
- { name: empty-block }
- { name: superfluous-else }
- { name: unreachable-code }
- { name: redefines-builtin-id }
- { name: range }
- { name: time-naming }
- { name: var-declaration }
gocritic:
enabled-tags: [diagnostic, performance, style]
disabled-checks:
- ifElseChain # overlaps revive/superfluous-else
- commentedOutCode
- hugeParam # pass-by-pointer micro-opt; hurts clarity, risks nil/aliasing
- rangeValCopy # same — copying a struct in range is usually fine
- unnamedResult # named returns are a style choice, not a defect
dupl:
threshold: 140
gosec:
excludes:
- G104 # unchecked errors — errcheck owns this
- G304 # file inclusion via variable — paths are config/run-file/worktree-derived, not user input

exclusions:
generated: lax # skip sqlc/codegen ("Code generated ... DO NOT EDIT")
rules:
# Tests: relax the noisiest checks (deliberate error-drops, repeated setup,
# preallocation, and upgrade-response bodies that don't need closing).
- path: _test\.go
linters: [errcheck, dupl, gosec, unparam, gocritic, prealloc, bodyclose]
# status.go deliberately reports probe failures in the result struct
# (st.State/st.Error) and returns nil — a down daemon is the status being
# reported, not a failure of the status command itself.
- path: internal/cli/status\.go
linters: [nilerr]
# The reflect/unsafe field-inspection test is intentional.
- path: wiring_test\.go
linters: [gosec]
# Spawning git/agent subprocesses with computed args is the point.
- linters: [gosec]
text: "G204"

formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/aoagents/agent-orchestrator
17 changes: 16 additions & 1 deletion backend/internal/adapters/runtime/tmux/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)

var getenv = os.Getenv

// Options configures a tmux Runtime; every field has a default (see New).
type Options struct {
Binary string
Timeout time.Duration
Shell string
}

// Runtime runs agent sessions inside tmux sessions, driving them via the tmux
// CLI. It implements ports.Runtime.
type Runtime struct {
binary string
timeout time.Duration
Expand All @@ -50,6 +53,8 @@ func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte,
return exec.CommandContext(ctx, name, args...).CombinedOutput()
}

// New builds a tmux Runtime, filling unset Options with defaults: binary
// "tmux", shell from $SHELL (else /bin/sh), and the default timeout.
func New(opts Options) *Runtime {
binary := opts.Binary
if binary == "" {
Expand All @@ -69,6 +74,8 @@ func New(opts Options) *Runtime {
return &Runtime{binary: binary, timeout: timeout, shell: shellPath, runner: execRunner{}}
}

// Create starts a new tmux session in the workspace, running the agent's
// launch command, and returns a handle to it.
func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
id, err := tmuxSessionName(cfg.SessionID)
if err != nil {
Expand All @@ -92,6 +99,8 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru
return ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}, nil
}

// Destroy kills the handle's tmux session. An already-gone session is treated
// as success.
func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error {
id, err := handleID(handle)
if err != nil {
Expand All @@ -107,6 +116,8 @@ func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error
return nil
}

// SendMessage types a message into the session's pane and presses Enter,
// routing large messages through a tmux paste buffer.
func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error {
id, err := handleID(handle)
if err != nil {
Expand All @@ -124,6 +135,7 @@ func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, m
return nil
}

// GetOutput captures the last `lines` lines of the session pane.
func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) {
id, err := handleID(handle)
if err != nil {
Expand All @@ -139,6 +151,7 @@ func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lin
return string(out), nil
}

// IsAlive reports whether the handle's tmux session still exists.
func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) {
id, err := handleID(handle)
if err != nil {
Expand All @@ -155,6 +168,8 @@ func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool
return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err)
}

// AttachCommand returns the argv a human runs to attach their terminal to the
// session.
func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) {
id, err := handleID(handle)
if err != nil {
Expand All @@ -170,7 +185,7 @@ func (r *Runtime) sendViaBuffer(ctx context.Context, id, message string) error {
return fmt.Errorf("tmux runtime: create message temp file: %w", err)
}
path := file.Name()
defer os.Remove(path)
defer func() { _ = os.Remove(path) }()
if _, err := file.WriteString(message); err != nil {
_ = file.Close()
return fmt.Errorf("tmux runtime: write message temp file: %w", err)
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/adapters/runtime/zellij/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func layoutString(workspacePath, shellPath string, shellArgs []string, shellComm

func shellLaunchCommand(cfg ports.RuntimeConfig, shellPath string, spec shellLaunchSpec) string {
if len(spec.args) > 0 && spec.args[0] == "-NoLogo" {
return wrapLaunchCommandPowerShell(cfg, shellPath)
return wrapLaunchCommandPowerShell(cfg)
}
if len(spec.args) > 0 && spec.args[0] == "/D" {
return wrapLaunchCommandCmd(cfg)
Expand Down Expand Up @@ -136,7 +136,7 @@ func wrapLaunchCommandUnix(cfg ports.RuntimeConfig, shellPath string) string {
return b.String()
}

func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig, shellPath string) string {
func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig) string {
path := cfg.Env["PATH"]
if path == "" {
path = getenv("PATH")
Expand Down
24 changes: 21 additions & 3 deletions backend/internal/adapters/runtime/zellij/zellij.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ const (
)

var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
var paneIDPattern = regexp.MustCompile(`^terminal_[0-9]+$`)
var paneIDPattern = regexp.MustCompile(`^terminal_\d+$`)

var getenv = os.Getenv

// Options configures a zellij Runtime; every field has a sensible default
// (see New), so the zero value is usable.
type Options struct {
Binary string
Timeout time.Duration
Expand All @@ -42,6 +44,8 @@ type Options struct {
ChunkSize int
}

// Runtime runs agent sessions inside zellij sessions, driving them via the
// zellij CLI. It implements ports.Runtime.
type Runtime struct {
binary string
timeout time.Duration
Expand All @@ -68,6 +72,9 @@ func (execRunner) Run(ctx context.Context, env []string, name string, args ...st
return cmd.CombinedOutput()
}

// New builds a zellij Runtime, filling unset Options with defaults: binary
// "zellij", shell from $SHELL (else /bin/sh, or powershell.exe on Windows), and
// the default timeout and output chunk size.
func New(opts Options) *Runtime {
binary := opts.Binary
if binary == "" {
Expand Down Expand Up @@ -95,6 +102,8 @@ func New(opts Options) *Runtime {
return &Runtime{binary: binary, timeout: timeout, shell: shellPath, socketDir: opts.SocketDir, configDir: opts.ConfigDir, chunkSize: chunkSize, runner: execRunner{}}
}

// Create starts a new zellij session in the workspace, running the agent's
// launch command, and returns a handle to it.
func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
id, err := zellijSessionName(cfg.SessionID)
if err != nil {
Expand All @@ -114,7 +123,7 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru
if err != nil {
return ports.RuntimeHandle{}, err
}
defer os.Remove(layoutPath)
defer func() { _ = os.Remove(layoutPath) }()

if _, err := r.run(ctx, createSessionArgs(id, layoutPath)...); err != nil {
return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: create session %s: %w", id, err)
Expand All @@ -127,6 +136,8 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru
return ports.RuntimeHandle{ID: handleIDValue(id, paneID), RuntimeName: runtimeName}, nil
}

// Destroy kills the handle's zellij session. An already-gone session is treated
// as success.
func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error {
id, _, err := handleID(handle)
if err != nil {
Expand All @@ -142,6 +153,8 @@ func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error
return nil
}

// SendMessage pastes a message into the session's pane (chunked) and presses
// Enter to submit it.
func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error {
id, paneID, err := handleID(handle)
if err != nil {
Expand All @@ -158,6 +171,7 @@ func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, m
return nil
}

// GetOutput returns the last `lines` lines of the session pane's screen dump.
func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) {
id, paneID, err := handleID(handle)
if err != nil {
Expand All @@ -173,6 +187,8 @@ func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lin
return tailLines(string(out), lines), nil
}

// IsAlive reports whether the handle's session still appears in `zellij
// list-sessions`.
func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) {
id, _, err := handleID(handle)
if err != nil {
Expand All @@ -189,6 +205,8 @@ func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool
return sessionListedAlive(string(out), id), nil
}

// AttachCommand returns the argv a human runs to attach their terminal to the
// session.
func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) {
id, _, err := handleID(handle)
if err != nil {
Expand Down Expand Up @@ -420,7 +438,7 @@ func chunks(s string, maxBytes int) []string {
return []string{s}
}
parts := []string{}
for len(s) > 0 {
for s != "" {
if len(s) <= maxBytes {
parts = append(parts, s)
break
Expand Down
3 changes: 3 additions & 0 deletions backend/internal/adapters/tracker/github/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ var ErrNoToken = errors.New("github tracker: no token configured")
// StaticTokenSource is a literal token, typically used in tests.
type StaticTokenSource string

// Token returns the literal token, or ErrNoToken if it is blank.
func (s StaticTokenSource) Token(context.Context) (string, error) {
t := strings.TrimSpace(string(s))
if t == "" {
Expand All @@ -39,6 +40,8 @@ type EnvTokenSource struct {
EnvVars []string
}

// Token returns the first non-empty configured env var (falling back to
// GITHUB_TOKEN), or ErrNoToken if none is set.
func (s EnvTokenSource) Token(context.Context) (string, error) {
for _, name := range s.EnvVars {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
Expand Down
Loading
Loading