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
40 changes: 40 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Scheduled fuzzing

on:
schedule:
- cron: "41 4 * * 2"
workflow_dispatch:

permissions:
contents: read

jobs:
fuzz:
name: fuzz-${{ matrix.target }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- package: ./internal/session
target: FuzzFrameDecoding
- package: ./internal/vterm
target: FuzzTerminalInput
- package: ./internal/displaytext
target: FuzzSanitize
- package: ./internal/session
target: FuzzAttachFiltering
- package: ./internal/session
target: FuzzStateDecoding
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
cache: true

- name: Fuzz for 30 seconds
run: go test "${{ matrix.package }}" -run=^$ -fuzz="^${{ matrix.target }}$" -fuzztime=30s
86 changes: 86 additions & 0 deletions .github/workflows/release-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Release smoke

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: release-smoke-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: true

jobs:
snapshot:
name: GoReleaser snapshot
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
cache: true

- name: Validate GoReleaser configuration
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
distribution: goreleaser
version: v2.17.0
args: check

- name: Build snapshot archives
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
distribution: goreleaser
version: v2.17.0
args: release --snapshot --clean --skip=sign,sbom

- name: Verify archive names and contents
shell: bash
run: |
mapfile -t archives < <(find dist -maxdepth 1 -type f -name 'uam_*.tar.gz' -print | sort)
test "${#archives[@]}" -eq 4
for archive in "${archives[@]}"; do
basename "${archive}" | grep -Eq '^uam_.+_(linux|darwin)_(amd64|arm64)\.tar\.gz$'
tar -tzf "${archive}" | grep -Eq '(^|/)uam$'
done
linux_archive="$(find dist -maxdepth 1 -type f -name 'uam_*_linux_amd64.tar.gz' -print -quit)"
test -n "${linux_archive}"
mkdir -p /tmp/uam-release-smoke
tar -xzf "${linux_archive}" -C /tmp/uam-release-smoke
binary="$(find /tmp/uam-release-smoke -type f -name uam -print -quit)"
test -n "${binary}"
"${binary}" version | grep -E '.+'

darwin-native:
name: Darwin-native lifecycle and version smoke
runs-on: macos-14
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
cache: true

- name: Run native session lifecycle tests
run: go test -race -count=1 -timeout=4m ./internal/session

- name: Run stop, restart, and resume tests
run: go test -race -count=1 -timeout=2m ./internal/app

- name: Build and run version command
run: |
go build -o "${RUNNER_TEMP}/uam" ./cmd/uam
"${RUNNER_TEMP}/uam" version | grep -E '.+'
9 changes: 5 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,23 @@ module github.com/RandomCodeSpace/unified-agent-manager

go 1.25.0

toolchain go1.25.11
toolchain go1.25.12

require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/term v0.2.1
github.com/charmbracelet/x/term v0.2.2
github.com/creack/pty v1.1.24
github.com/mattn/go-runewidth v0.0.16
github.com/mattn/go-runewidth v0.0.24
golang.org/x/sys v0.36.0
)

require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
Expand All @@ -26,6 +28,5 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
11 changes: 6 additions & 5 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
Expand All @@ -22,15 +24,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
Expand Down
20 changes: 20 additions & 0 deletions internal/adapter/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package adapter

import (
"context"
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -57,6 +58,25 @@ func TestRegistryListAllUsesSingleBackendSnapshot(t *testing.T) {
}
}

func BenchmarkRegistryListAllSharedSnapshot(b *testing.B) {
infos := make([]session.Info, 300)
for i := range infos {
infos[i] = session.Info{Name: fmt.Sprintf("uam-a-%08x", i+1), CreatedUnix: time.Now().Unix(), Alive: true}
}
backend := &adaptertest.Backend{Sessions: infos}
adapters := make([]AgentAdapter, 0, 6)
for _, name := range []string{"a", "b", "c", "d", "e", "f"} {
adapters = append(adapters, NewAgent(name, name, []CommandCandidate{{Display: "sh", Args: []string{"/bin/sh"}}}, nil, backend))
}
registry := NewRegistryWithBackend(backend, adapters)
b.ResetTimer()
for b.Loop() {
if _, err := registry.ListAll(context.Background()); err != nil {
b.Fatal(err)
}
}
}

type fakeAdapter struct {
name string
available bool
Expand Down
42 changes: 42 additions & 0 deletions internal/app/pr_refresh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"sync"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -168,3 +169,44 @@ func TestAdapterStatusMapping(t *testing.T) {
}
}
}

func BenchmarkRefreshPRStatuses(b *testing.B) {
sessions := make([]adapter.Session, 20)
for i := range sessions {
id := fmt.Sprintf("%08x", i+1)
sessions[i] = adapter.Session{
ID: id, AgentType: "fake", DisplayName: id, Cwd: "/tmp",
SessionName: "uam-fake-" + id, State: adapter.Active, ProcAlive: adapter.Alive,
CreatedAt: time.Now(), PR: &adapter.PRRef{URL: fmt.Sprintf("https://github.com/o/r/pull/%d", i+1), Number: i + 1, Status: adapter.PROpen},
}
}
st, err := store.Open(filepath.Join(b.TempDir(), "sessions.json"))
if err != nil {
b.Fatal(err)
}
fake := &svcFakeAdapter{name: "fake", available: true, sessions: sessions}
svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake}))
svc.checkPR = func(context.Context, string) (pr.Status, error) { return pr.Open, nil }
if _, _, err := svc.LoadSessions(context.Background()); err != nil {
b.Fatal(err)
}
b.ResetTimer()
for b.Loop() {
b.StopTimer()
if err := st.Update(func(cfg *store.Config) error {
for key, rec := range cfg.Sessions {
if rec.PR != nil {
rec.PR.LastChecked = time.Time{}
cfg.Sessions[key] = rec
}
}
return nil
}); err != nil {
b.Fatal(err)
}
b.StartTimer()
if err := svc.RefreshPRStatuses(context.Background()); err != nil {
b.Fatal(err)
}
}
}
21 changes: 21 additions & 0 deletions internal/app/render_cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,27 @@ func TestTruncateIsWidthAware(t *testing.T) {
}
}

func TestTruncateUnicodeGolden(t *testing.T) {
for _, tc := range []struct {
name string
in string
cols int
want string
}{
{name: "emoji", in: "🚀🚀🚀", cols: 5, want: "🚀🚀…"},
{name: "combining", in: "e\u0301clair", cols: 3, want: "e\u0301c…"},
{name: "cjk", in: "你好世界", cols: 5, want: "你好…"},
{name: "narrow", in: "anything", cols: 1, want: "…"},
{name: "pasted-multibyte-name", in: "部署 café 🚀", cols: 10, want: "部署 café…"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := truncate(tc.in, tc.cols); got != tc.want {
t.Fatalf("truncate(%q, %d) = %q, want %q", tc.in, tc.cols, got, tc.want)
}
})
}
}

// F28 — truncate must not chop a multibyte rune mid-sequence (the old byte-slice
// path produced mojibake on the boundary).
func TestTruncateNeverEmitsMojibake(t *testing.T) {
Expand Down
13 changes: 13 additions & 0 deletions internal/session/attach_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ func runFilter(t *testing.T, f *stdinFilter, chunks ...string) (string, bool) {
return out.String(), false
}

func FuzzAttachFiltering(f *testing.F) {
for _, seed := range []string{"plain text", "世界🚀", "\x02d", "\x1b[D", "\x1b]11;rgb:ffff/ffff/ffff\x1b\\"} {
f.Add(seed, true)
}
f.Fuzz(func(t *testing.T, input string, backDetach bool) {
filter := &stdinFilter{backDetach: backDetach}
out, _ := filter.filter([]byte(input))
if len(out) > 2*len(input)+2 {
t.Fatalf("filter expanded %d input bytes to %d bytes", len(input), len(out))
}
})
}

func TestLeftArrowDetachesWhenNothingTyped(t *testing.T) {
f := &stdinFilter{backDetach: true}
out, detach := runFilter(t, f, "\x1b[D")
Expand Down
29 changes: 29 additions & 0 deletions internal/session/liveness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package session

import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
Expand All @@ -12,6 +13,34 @@ import (
"testing"
)

func FuzzStateDecoding(f *testing.F) {
for _, seed := range [][]byte{
[]byte(`{"name":"uam-fake-aabbccdd","host_pid":1,"host_start":2}`),
[]byte(`{"name":"../escape","host_pid":1}`),
[]byte(`null`),
[]byte(`{"unknown":{"nested":true}}`),
} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, data []byte) {
var state State
if err := json.Unmarshal(data, &state); err != nil {
return
}
encoded, err := json.Marshal(state)
if err != nil {
t.Fatal(err)
}
var decoded State
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Name != state.Name || decoded.HostPID != state.HostPID || decoded.HostStart != state.HostStart {
t.Fatalf("state round trip changed identity: before=%+v after=%+v", state, decoded)
}
})
}

func TestVerifyDirRejectsUnsafeRuntimeDirectories(t *testing.T) {
parent := t.TempDir()

Expand Down
25 changes: 25 additions & 0 deletions internal/session/proto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,28 @@ func TestFrameWriterSerializesConcurrentFrames(t *testing.T) {
t.Fatalf("trailing read error = %v, want EOF", err)
}
}

func FuzzFrameDecoding(f *testing.F) {
for _, seed := range [][]byte{
{frameDetach, 0, 0, 0, 0},
{frameStdin, 0, 0, 0, 3, 'a', 'b', 'c'},
{frameResize, 0, 0, 0, 4, 0, 80, 0, 24},
{frameStdin, 0xff, 0xff, 0xff, 0xff},
} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, data []byte) {
kind, payload, err := readFrame(bytes.NewReader(data))
if err != nil {
return
}
var roundTrip bytes.Buffer
if err := writeFrame(&roundTrip, kind, payload); err != nil {
t.Fatal(err)
}
gotKind, gotPayload, err := readFrame(&roundTrip)
if err != nil || gotKind != kind || !bytes.Equal(gotPayload, payload) {
t.Fatalf("frame round trip = (%d, %x, %v), want (%d, %x)", gotKind, gotPayload, err, kind, payload)
}
})
}
Loading
Loading