From a0187b3cb5dfc2fe238bdf10592b83fc4190099f Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 12 Jul 2026 09:14:19 +0000 Subject: [PATCH 1/6] ci: enforce least-privilege release gates --- .github/workflows/ci.yml | 69 ++++++++++++++++++++++++++-------- .github/workflows/release.yml | 57 ++++++++++++++++++++++++---- .github/workflows/security.yml | 46 ++++++++--------------- .github/workflows/sonar.yml | 21 +++-------- README.md | 10 +++-- 5 files changed, 131 insertions(+), 72 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0473cc4..559a197 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,44 +3,83 @@ name: CI on: push: pull_request: + workflow_call: + workflow_dispatch: permissions: contents: read jobs: - build-test-lint: - name: Build, test, and lint + build: + name: build runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: go.mod cache: true + - name: Verify modules + run: go mod verify + - name: Build run: go build ./... - - name: Vet - run: go vet ./... + test-race: + name: test-race + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + + - name: Test with race detector and coverage + run: go test -race -count=1 -covermode=atomic -coverpkg=./... -coverprofile=coverage.out ./... + + - name: Enforce coverage floor + shell: bash + run: | + coverage="$(go tool cover -func=coverage.out | awk '$1 == "total:" {gsub("%", "", $3); print $3}')" + awk -v coverage="${coverage}" 'BEGIN { if (coverage + 0 < 88.1) { printf "coverage %.1f%% is below 88.1%%\n", coverage; exit 1 } }' + + lint: + name: lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true - name: Check gofmt + shell: bash run: | unformatted="$(gofmt -l . | grep -v '^vendor/' || true)" - if [ -n "$unformatted" ]; then - echo "The following files are not gofmt-clean:" - echo "$unformatted" - exit 1 - fi + test -z "${unformatted}" || { printf 'The following files are not gofmt-clean:\n%s\n' "${unformatted}"; exit 1; } + + - name: Vet + run: go vet ./... - - name: Test - run: go test -race -cover ./... + - name: Staticcheck + run: | + go install honnef.co/go/tools/cmd/staticcheck@2025.1.1 + staticcheck ./... - - name: Lint - uses: golangci/golangci-lint-action@9fae48acfc02a90574d7c304a1758ef9895495fa # v7 + - name: GolangCI-Lint + uses: golangci/golangci-lint-action@9fae48acfc02a90574d7c304a1758ef9895495fa # v7.0.1 with: version: v2.5.0 install-mode: goinstall diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0d2cc6..bb8a8dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,22 +6,63 @@ on: - "v*" permissions: - contents: write - id-token: write - packages: read + contents: read jobs: - goreleaser: - name: GoReleaser + quality: + name: Quality verification + uses: ./.github/workflows/ci.yml + + security: + name: Security verification + uses: ./.github/workflows/security.yml + + ancestry: + name: Verify tag is on main + runs-on: ubuntu-latest + steps: + - name: Checkout tagged commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Verify tagged commit is an ancestor of main + run: | + git fetch --no-tags origin main + git merge-base --is-ancestor "${GITHUB_SHA}" origin/main + + release-config: + name: Validate release configuration + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Validate GoReleaser configuration + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + with: + version: "~> v2" + args: check + + publish: + name: Publish release + needs: [quality, security, ancestry, release-config] runs-on: ubuntu-latest + environment: release + permissions: + contents: write + id-token: write + packages: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: go.mod cache: true @@ -33,7 +74,7 @@ jobs: uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0 - name: Run GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 with: version: "~> v2" args: release --clean diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 8d43616..3af1054 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -4,72 +4,58 @@ on: push: branches: [main] pull_request: - branches: [main] schedule: - cron: "23 3 * * 1" + workflow_call: workflow_dispatch: permissions: contents: read - actions: read - security-events: write - pull-requests: read jobs: govulncheck: - name: Go vulnerability check + name: govulncheck runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: go.mod cache: true - - name: Install govulncheck - run: go install golang.org/x/vuln/cmd/govulncheck@v1.3.0 - - name: Run govulncheck - run: govulncheck ./... + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.3.0 + govulncheck ./... gosec: - name: Gosec static analysis + name: gosec runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: go.mod cache: true - - name: Install gosec - run: go install github.com/securego/gosec/v2/cmd/gosec@v2.26.1 - - name: Run gosec - run: gosec -exclude-dir=.claude -exclude-dir=vendor -fmt=sarif -out=gosec.sarif ./... - - - name: Upload gosec SARIF - if: always() - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: gosec.sarif + run: | + go install github.com/securego/gosec/v2/cmd/gosec@v2.26.1 + gosec -exclude-dir=.claude -exclude-dir=vendor ./... dependency-review: - name: Dependency review + name: dependency-review if: github.event_name == 'pull_request' runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Review dependency changes - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 2505e4b..5b39293 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -3,9 +3,8 @@ name: SonarCloud on: push: branches: [main] - pull_request: - branches: [main] - workflow_dispatch: + schedule: + - cron: "17 5 * * 3" permissions: contents: read @@ -15,16 +14,14 @@ jobs: sonarcloud: name: SonarCloud scan runs-on: ubuntu-latest - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: go.mod cache: true @@ -34,15 +31,7 @@ jobs: - name: Run SonarCloud scan if: env.SONAR_TOKEN != '' - uses: SonarSource/sonarqube-scan-action@v6 + uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - name: Explain skipped SonarCloud scan - if: env.SONAR_TOKEN == '' - run: | - echo "SONAR_TOKEN is not configured, so the SonarCloud scan was skipped." - echo "Add a repository secret named SONAR_TOKEN to enable this check." - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/README.md b/README.md index 5b8e8af..02750eb 100644 --- a/README.md +++ b/README.md @@ -189,9 +189,13 @@ run `loginctl enable-linger`. ## Safety model -`uam` can launch providers in their full-access or auto-approve mode when the -provider supports it. Use `uam dispatch --safe ...` when you want the provider's -default approval behavior instead. +`uam` launches providers in their full-access or auto-approve ("yolo") mode by +default when the provider supports it. In that mode, treat the repository, +prompt, provider configuration, and any instructions the agent reads as trusted: +the provider may execute commands and change files without pausing for approval. +Use `uam dispatch --safe ...` when you want the provider's default approval +behavior instead. Safe mode changes provider arguments; it is not an operating- +system sandbox and does not reduce the permissions of the `uam` process itself. `uam` does not make git checkpoints, stash changes, or modify your repository on its own. It starts and manages agent sessions; the provider remains responsible From 3f2f08d11b3d889443b17ce473cbe86586839a60 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 12 Jul 2026 09:21:47 +0000 Subject: [PATCH 2/6] test: restore coverage and remove duplicate routing --- internal/cli/cli.go | 15 --------------- internal/cli/cli_test.go | 10 ++++------ internal/cli/killall_test.go | 9 +++------ internal/log/log_test.go | 7 +++++++ internal/session/host.go | 9 ++------- internal/session/session_test.go | 8 ++++++++ internal/store/store_test.go | 3 +++ 7 files changed, 27 insertions(+), 34 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 1df5d85..58c0c1d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -145,14 +145,8 @@ func runWithoutStore(ctx context.Context, args []string) (bool, error) { func runCommand(ctx context.Context, svc *app.Service, args []string, runTUI func(context.Context, tea.Model) error) error { switch args[0] { - case "-h", "--help", "help": - Usage() - return nil case "new": return runNew(ctx, svc, runTUI) - case "version", "--version": - fmt.Println(version.String()) - return nil case "dispatch": return RunDispatch(ctx, svc, args[1:]) case "ls", "list": @@ -165,15 +159,6 @@ func runCommand(ctx context.Context, svc *app.Service, args []string, runTUI fun return runRestart(ctx, svc, args[1:]) case "notify-closed": return runNotifyClosed(svc, args[1:]) - case "kill-all": - return runKillAll(ctx, session.NewClient().KillAll) - case "__host": - // Internal: the detached per-session host process (see - // internal/session). Spawned by CreateSession, never typed by hand. - return session.RunHost(args[1:]) - case "__attach": - // Internal: the interactive attach client run by AttachSpec argv. - return session.RunAttach(args[1:]) case "attach": id, err := requireArg(args[1:], "attach requires ") if err != nil { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 8f712cf..6bffe19 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -403,16 +403,14 @@ func firstNonEmpty(values ...string) string { return "" } -// The internal __host/__attach subcommands are routed through runCommand; +// The internal __host/__attach subcommands are routed before store access; // invalid input must surface as errors rather than silently doing nothing. -func TestRunCommandInternalSubcommands(t *testing.T) { +func TestRunWithoutStoreInternalSubcommands(t *testing.T) { t.Setenv("UAM_SESSION_DIR", secureSessionDir(t)) - svc, _ := newCLITestService(t) - noTUI := func(context.Context, tea.Model) error { return nil } - if err := runCommand(context.Background(), svc, []string{"__host", "--name", "bad name", "--", "/bin/true"}, noTUI); err == nil { + if handled, err := runWithoutStore(context.Background(), []string{"__host", "--name", "bad name", "--", "/bin/true"}); !handled || err == nil { t.Fatal("__host with an invalid name must fail") } - if err := runCommand(context.Background(), svc, []string{"__attach"}, noTUI); err == nil { + if handled, err := runWithoutStore(context.Background(), []string{"__attach"}); !handled || err == nil { t.Fatal("__attach without a session must fail") } } diff --git a/internal/cli/killall_test.go b/internal/cli/killall_test.go index 0ea7025..d28c512 100644 --- a/internal/cli/killall_test.go +++ b/internal/cli/killall_test.go @@ -4,8 +4,6 @@ import ( "context" "errors" "testing" - - tea "github.com/charmbracelet/bubbletea" ) // F24 — `uam kill-all` must invoke the session teardown exactly once. @@ -32,15 +30,14 @@ func TestRunKillAllPropagatesError(t *testing.T) { } } -// F24 — `kill-all` must be routed by runCommand to the default teardown path. +// F24 — `kill-all` must be routed before store access to the default teardown path. // An empty session runtime dir (via UAM_SESSION_DIR) keeps the test off any // real sessions: KillAll over zero sessions is an idempotent success. -func TestRunCommandKillAllDispatches(t *testing.T) { - svc, _ := newCLITestService(t) +func TestRunWithoutStoreKillAllDispatches(t *testing.T) { t.Setenv("UAM_SESSION_DIR", secureSessionDir(t)) out := captureCLIStdout(t, func() { - if err := runCommand(context.Background(), svc, []string{"kill-all"}, func(context.Context, tea.Model) error { return nil }); err != nil { + if handled, err := runWithoutStore(context.Background(), []string{"kill-all"}); !handled || err != nil { t.Fatalf("kill-all dispatch: %v", err) } }) diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 9af8b5f..f644743 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -158,4 +158,11 @@ func TestCacheDirFallbacks(t *testing.T) { if got := cacheDir(); got != filepath.Join(dir, "uam") { t.Fatalf("%s", got) } + t.Setenv("XDG_CACHE_HOME", "") + t.Setenv("HOME", dir) + if got := cacheDir(); got != filepath.Join(dir, ".cache", "uam") { + t.Fatalf("home cache dir = %s", got) + } + UseStderr(nil) + Debug("stderr fallback debug path") } diff --git a/internal/session/host.go b/internal/session/host.go index 93dd274..971ac78 100644 --- a/internal/session/host.go +++ b/internal/session/host.go @@ -17,6 +17,7 @@ import ( "github.com/creack/pty" + "github.com/RandomCodeSpace/unified-agent-manager/internal/displaytext" "github.com/RandomCodeSpace/unified-agent-manager/internal/log" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" "github.com/RandomCodeSpace/unified-agent-manager/internal/vterm" @@ -369,13 +370,7 @@ func (h *host) setLabel(label string) { // titleSequence sets the terminal title via OSC 0 — the native stand-in for // tmux's set-titles-string showing the user-facing session label. func titleSequence(label string) string { - clean := strings.Map(func(r rune) rune { - if r < 0x20 || r == 0x7f { - return -1 - } - return r - }, label) - return "\x1b]0;" + clean + "\x07" + return "\x1b]0;" + displaytext.Sanitize(label) + "\x07" } func validSize(cols, rows int) bool { diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 5201d90..d16dcbf 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -309,6 +309,14 @@ func TestSetSessionLabelPersistsToState(t *testing.T) { }) } +func TestTitleSequenceSanitizesTerminalControls(t *testing.T) { + got := titleSequence("safe\u009d0;forged\a red\nnow") + want := "\x1b]0;safe red now\x07" + if got != want { + t.Fatalf("titleSequence = %q, want %q", got, want) + } +} + func TestListSweepsStaleStateFiles(t *testing.T) { c := newTestClient(t) if err := EnsureDir(c.Dir); err != nil { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 026a420..55be9a1 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -104,6 +104,9 @@ func TestTryMarkSessionClosedReportsWhetherRecordMatched(t *testing.T) { if rec.Status != StatusClosedByUser || rec.LastExitCode == nil || *rec.LastExitCode != 7 { t.Fatalf("record not closed: %+v", rec) } + if err := s.MarkSessionClosed("uam-fake-deadbeef", 8); err != nil { + t.Fatalf("idempotent MarkSessionClosed: %v", err) + } } func TestSyncDirAcceptsStoreDirectoryAndRejectsMissingDirectory(t *testing.T) { From 071050e5c439a57abd1cf05dc6ea6f0bf65cbecd Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 12 Jul 2026 09:23:34 +0000 Subject: [PATCH 3/6] fix(pr): enforce refresh pass deadlines --- internal/app/pr_refresh_test.go | 22 ++++++++++++++++++++++ internal/app/service.go | 24 +++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/app/pr_refresh_test.go b/internal/app/pr_refresh_test.go index 2a48e21..2601f36 100644 --- a/internal/app/pr_refresh_test.go +++ b/internal/app/pr_refresh_test.go @@ -170,6 +170,28 @@ func TestAdapterStatusMapping(t *testing.T) { } } +func TestCheckPRWithContextReturnsWhenCheckerIgnoresCancellation(t *testing.T) { + release := make(chan struct{}) + checkerDone := make(chan struct{}) + checker := func(context.Context, string) (pr.Status, error) { + defer close(checkerDone) + <-release + return pr.Merged, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + started := time.Now() + status, err := checkPRWithContext(ctx, checker, "https://github.com/o/r/pull/5") + if !errors.Is(err, context.DeadlineExceeded) || status != pr.None { + t.Fatalf("checkPRWithContext = (%q, %v), want deadline", status, err) + } + if elapsed := time.Since(started); elapsed > 250*time.Millisecond { + t.Fatalf("cancellation-ignoring checker blocked for %v", elapsed) + } + close(release) + <-checkerDone +} + func BenchmarkRefreshPRStatuses(b *testing.B) { sessions := make([]adapter.Session, 20) for i := range sessions { diff --git a/internal/app/service.go b/internal/app/service.go index 2d130cc..0869ffb 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -327,6 +327,28 @@ type prRefreshResult struct { record store.PRRecord } +type prCheckResult struct { + status pr.Status + err error +} + +// checkPRWithContext enforces the caller's deadline even when a custom checker +// fails to observe context cancellation. The result channel is buffered so a +// late checker can finish without retaining the refresh worker. +func checkPRWithContext(ctx context.Context, checker func(context.Context, string) (pr.Status, error), url string) (pr.Status, error) { + resultCh := make(chan prCheckResult, 1) + go func() { + status, err := checker(ctx, url) + resultCh <- prCheckResult{status: status, err: err} + }() + select { + case result := <-resultCh: + return result.status, result.err + case <-ctx.Done(): + return pr.None, ctx.Err() + } +} + // RefreshPRStatuses enriches stale discovered PRs outside the session-discovery // path. Only PR-owned fields are persisted, and overlapping passes coalesce. func (s *Service) RefreshPRStatuses(ctx context.Context) error { @@ -370,7 +392,7 @@ func (s *Service) RefreshPRStatuses(ctx context.Context) error { for job := range jobCh { status := job.ref.Status checkCtx, checkCancel := context.WithTimeout(ctx, prCheckTimeout) - checked, checkErr := checker(checkCtx, job.ref.URL) + checked, checkErr := checkPRWithContext(checkCtx, checker, job.ref.URL) checkCancel() if checkErr == nil && checked != pr.None { status = adapterStatus(checked) From ae1f883b1533dda08e61aa1cdc0015bd2462afc8 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 12 Jul 2026 09:32:37 +0000 Subject: [PATCH 4/6] refactor: simplify hardened control paths --- internal/adapter/agent.go | 25 +++++--- internal/app/service.go | 102 +++++++++++++++++++------------ internal/displaytext/sanitize.go | 44 +++++++------ internal/session/client.go | 80 ++++++++++++++---------- internal/session/host.go | 14 +++-- 5 files changed, 160 insertions(+), 105 deletions(-) diff --git a/internal/adapter/agent.go b/internal/adapter/agent.go index 5dc5a74..76036f4 100644 --- a/internal/adapter/agent.go +++ b/internal/adapter/agent.go @@ -219,16 +219,7 @@ func (a *Agent) startSession(ctx context.Context, req ResumeRequest, activity st if err := a.Backend.CreateSession(ctx, sessionName, cwd, env, cmd); err != nil { return Session{}, fmt.Errorf("create session %s: %w", sessionName, err) } - // Surface the user-facing name in attached terminals' titles (the - // canonical uam-- stays the machine-parseable session name). - // Cosmetic and best-effort — a failure never affects the session. - displayName := req.Name - if displayName == "" { - displayName = displayNameFromDir(cwd) - } - if err := a.Backend.SetSessionLabel(ctx, sessionName, displaytext.Sanitize(displayName+" · "+a.Name())); err != nil { - log.Debug("set session label failed", "session", sessionName, "error", err) - } + displayName := a.setSessionDisplayLabel(ctx, sessionName, req.Name, cwd) shouldSendPrompt := strings.TrimSpace(req.Prompt) != "" && (activity != "resumed" || !a.SkipPromptOnResume) if shouldSendPrompt { if err := a.Backend.SendLine(ctx, sessionName, req.Prompt); err != nil { @@ -254,6 +245,20 @@ func (a *Agent) startSession(ctx context.Context, req ResumeRequest, activity st return Session{ID: req.ID, AgentType: a.Name(), CommandAlias: req.CommandAlias, DisplayName: displayName, Prompt: req.Prompt, Cwd: cwd, SessionName: sessionName, ProviderSessionID: providerID, State: Active, ProcAlive: Alive, CreatedAt: created, LastChange: now}, nil } +// setSessionDisplayLabel surfaces the user-facing name in attached terminal +// titles while keeping the canonical session name machine-parseable. It is +// cosmetic and best-effort: failure never changes session startup. +func (a *Agent) setSessionDisplayLabel(ctx context.Context, sessionName, requestedName, cwd string) string { + displayName := requestedName + if displayName == "" { + displayName = displayNameFromDir(cwd) + } + if err := a.Backend.SetSessionLabel(ctx, sessionName, displaytext.Sanitize(displayName+" · "+a.Name())); err != nil { + log.Debug("set session label failed", "session", sessionName, "error", err) + } + return displayName +} + func resolveSessionCwd(cwd string) (string, error) { if cwd == "" { var err error diff --git a/internal/app/service.go b/internal/app/service.go index 0869ffb..3b66326 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -123,28 +123,32 @@ func (s *Service) persistRefresh(updates map[string]refreshPatch) error { } return s.Store.Update(func(cfg *store.Config) error { for key, patch := range updates { - rec, exists := cfg.Sessions[key] - if (!exists || rec.ID == "") && patch.create != nil { - rec = *patch.create - } - if patch.status != nil { - rec.Status = *patch.status - } - if patch.lastSeenAt != nil { - rec.LastSeenAt = *patch.lastSeenAt - } - if patch.clearPR { - rec.PR = nil - } else if patch.pr != nil { - pr := *patch.pr - rec.PR = &pr - } - cfg.Sessions[key] = rec + applyRefreshPatch(cfg, key, patch) } return nil }) } +func applyRefreshPatch(cfg *store.Config, key string, patch refreshPatch) { + rec, exists := cfg.Sessions[key] + if (!exists || rec.ID == "") && patch.create != nil { + rec = *patch.create + } + if patch.status != nil { + rec.Status = *patch.status + } + if patch.lastSeenAt != nil { + rec.LastSeenAt = *patch.lastSeenAt + } + if patch.clearPR { + rec.PR = nil + } else if patch.pr != nil { + pr := *patch.pr + rec.PR = &pr + } + cfg.Sessions[key] = rec +} + func (s *Service) loadConfig() (store.Config, error) { cfg := store.DefaultConfig() if s.Store == nil { @@ -362,25 +366,41 @@ func (s *Service) RefreshPRStatuses(ctx context.Context) error { if err != nil { return err } - now := time.Now() + jobs := stalePRRefreshJobs(sessions, cfg, time.Now()) + if len(jobs) == 0 { + return nil + } + results := runPRRefreshJobs(ctx, jobs, firstPRChecker(s.checkPR)) + if err := s.persistPRRefreshResults(results); err != nil { + return err + } + return ctx.Err() +} + +func stalePRRefreshJobs(sessions []adapter.Session, cfg store.Config, now time.Time) []prRefreshJob { jobs := make([]prRefreshJob, 0, len(sessions)) for _, sess := range sessions { if sess.PR == nil { continue } - rec := cfg.Sessions[store.Key(sess.AgentType, sess.ID)] + key := store.Key(sess.AgentType, sess.ID) + rec := cfg.Sessions[key] if rec.PR != nil && rec.PR.URL == sess.PR.URL && now.Sub(rec.PR.LastChecked) < prRefreshAge { continue } - jobs = append(jobs, prRefreshJob{key: store.Key(sess.AgentType, sess.ID), ref: *sess.PR}) + jobs = append(jobs, prRefreshJob{key: key, ref: *sess.PR}) } - if len(jobs) == 0 { - return nil - } - checker := s.checkPR - if checker == nil { - checker = pr.Check + return jobs +} + +func firstPRChecker(checker func(context.Context, string) (pr.Status, error)) func(context.Context, string) (pr.Status, error) { + if checker != nil { + return checker } + return pr.Check +} + +func runPRRefreshJobs(ctx context.Context, jobs []prRefreshJob, checker func(context.Context, string) (pr.Status, error)) []prRefreshResult { jobCh := make(chan prRefreshJob) resultCh := make(chan prRefreshResult, len(jobs)) workers := min(prRefreshWorkers, len(jobs)) @@ -390,14 +410,7 @@ func (s *Service) RefreshPRStatuses(ctx context.Context) error { go func() { defer wg.Done() for job := range jobCh { - status := job.ref.Status - checkCtx, checkCancel := context.WithTimeout(ctx, prCheckTimeout) - checked, checkErr := checkPRWithContext(checkCtx, checker, job.ref.URL) - checkCancel() - if checkErr == nil && checked != pr.None { - status = adapterStatus(checked) - } - resultCh <- prRefreshResult{key: job.key, record: store.PRRecord{URL: job.ref.URL, Number: job.ref.Number, LastStatus: string(status), LastChecked: time.Now()}} + resultCh <- runPRRefreshJob(ctx, job, checker) } }() } @@ -417,7 +430,22 @@ func (s *Service) RefreshPRStatuses(ctx context.Context) error { for result := range resultCh { results = append(results, result) } - updateErr := s.Store.Update(func(cfg *store.Config) error { + return results +} + +func runPRRefreshJob(ctx context.Context, job prRefreshJob, checker func(context.Context, string) (pr.Status, error)) prRefreshResult { + status := job.ref.Status + checkCtx, checkCancel := context.WithTimeout(ctx, prCheckTimeout) + checked, checkErr := checkPRWithContext(checkCtx, checker, job.ref.URL) + checkCancel() + if checkErr == nil && checked != pr.None { + status = adapterStatus(checked) + } + return prRefreshResult{key: job.key, record: store.PRRecord{URL: job.ref.URL, Number: job.ref.Number, LastStatus: string(status), LastChecked: time.Now()}} +} + +func (s *Service) persistPRRefreshResults(results []prRefreshResult) error { + return s.Store.Update(func(cfg *store.Config) error { for _, result := range results { rec, ok := cfg.Sessions[result.key] if !ok || rec.PR == nil || rec.PR.URL != result.record.URL { @@ -429,10 +457,6 @@ func (s *Service) RefreshPRStatuses(ctx context.Context) error { } return nil }) - if updateErr != nil { - return updateErr - } - return ctx.Err() } func adapterStatus(status pr.Status) adapter.PRStatus { diff --git a/internal/displaytext/sanitize.go b/internal/displaytext/sanitize.go index 78116c8..76a600f 100644 --- a/internal/displaytext/sanitize.go +++ b/internal/displaytext/sanitize.go @@ -45,16 +45,7 @@ func Sanitize(s string) string { func step(state parseState, r rune, b *strings.Builder) parseState { switch state { case escape: - switch r { - case '[': - return csi - case ']', 'P', 'X', '^', '_': - return controlString - case 0x1b: - return escape - default: - return appendGround(r, b) - } + return stepEscape(r, b) case csi: if r == 0x1b { return escape @@ -64,14 +55,7 @@ func step(state parseState, r rune, b *strings.Builder) parseState { } return csi case controlString: - switch r { - case 0x07, 0x9c: - return ground - case 0x1b: - return controlStringEscape - default: - return controlString - } + return stepControlString(r) case controlStringEscape: if r == '\\' || r == 0x9c || r == 0x07 { return ground @@ -85,6 +69,30 @@ func step(state parseState, r rune, b *strings.Builder) parseState { } } +func stepEscape(r rune, b *strings.Builder) parseState { + switch r { + case '[': + return csi + case ']', 'P', 'X', '^', '_': + return controlString + case 0x1b: + return escape + default: + return appendGround(r, b) + } +} + +func stepControlString(r rune) parseState { + switch r { + case 0x07, 0x9c: + return ground + case 0x1b: + return controlStringEscape + default: + return controlString + } +} + func appendGround(r rune, b *strings.Builder) parseState { switch r { case 0x1b: diff --git a/internal/session/client.go b/internal/session/client.go index 8b381cb..dd7e242 100644 --- a/internal/session/client.go +++ b/internal/session/client.go @@ -168,32 +168,36 @@ func (c *Client) List(_ context.Context) ([]Info, error) { } var out []Info for _, e := range entries { - name, isState := strings.CutSuffix(e.Name(), ".json") - if !isState || ValidateName(name) != nil { - continue + if info, keep := c.infoFromStateEntry(e); keep { + out = append(out, info) } - st, err := readState(c.Dir, name) - if err != nil { - log.Warn("skipping unreadable session state", "file", e.Name(), "error", err) - continue - } - if !st.hostAlive() { - if st.childAlive() { - // Host crashed but the agent is still winding down (it gets - // SIGHUP when the PTY master closed). Keep it visible and - // leave the files for the next sweep. - out = append(out, infoFromState(st)) - continue - } - _ = removeSessionFiles(c.Dir, name) - continue - } - out = append(out, infoFromState(st)) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, nil } +func (c *Client) infoFromStateEntry(entry os.DirEntry) (Info, bool) { + name, isState := strings.CutSuffix(entry.Name(), ".json") + if !isState || ValidateName(name) != nil { + return Info{}, false + } + st, err := readState(c.Dir, name) + if err != nil { + log.Warn("skipping unreadable session state", "file", entry.Name(), "error", err) + return Info{}, false + } + if st.hostAlive() { + return infoFromState(st), true + } + if st.childAlive() { + // Host crashed but the agent is still winding down. Keep it visible + // and leave the runtime files for the next sweep. + return infoFromState(st), true + } + _ = removeSessionFiles(c.Dir, name) + return Info{}, false +} + func infoFromState(st State) Info { cwd := procCwd(st.ChildPID) if cwd == "" { @@ -257,19 +261,8 @@ func (c *Client) Kill(ctx context.Context, name string) error { // A live PID is not sufficient authority to signal: fallback control paths // require a nonzero matching start identity so a recycled PID can never be // signalled as if it were the session. - if ProcAlive(st.HostPID) { - if !procIdentityMatches(st.HostPID, st.HostStart) { - return fmt.Errorf("kill session %s: cannot verify process identity for host pid %d", name, st.HostPID) - } - _ = syscall.Kill(st.HostPID, syscall.SIGTERM) - } else if ProcAlive(st.ChildPID) { - if !procIdentityMatches(st.ChildPID, st.ChildStart) { - return fmt.Errorf("kill session %s: cannot verify process identity for child pid %d", name, st.ChildPID) - } - // Orphaned agent (host crashed): signal its process group directly. - if err := syscall.Kill(-st.ChildPID, syscall.SIGTERM); err != nil { - _ = syscall.Kill(st.ChildPID, syscall.SIGTERM) - } + if err := signalVerifiedFallback(name, st); err != nil { + return err } deadline := time.Now().Add(callTimeout) for time.Now().Before(deadline) { @@ -286,6 +279,27 @@ func (c *Client) Kill(ctx context.Context, name string) error { return fmt.Errorf("kill session %s: still running", name) } +func signalVerifiedFallback(name string, st State) error { + if ProcAlive(st.HostPID) { + if !procIdentityMatches(st.HostPID, st.HostStart) { + return fmt.Errorf("kill session %s: cannot verify process identity for host pid %d", name, st.HostPID) + } + _ = syscall.Kill(st.HostPID, syscall.SIGTERM) + return nil + } + if !ProcAlive(st.ChildPID) { + return nil + } + if !procIdentityMatches(st.ChildPID, st.ChildStart) { + return fmt.Errorf("kill session %s: cannot verify process identity for child pid %d", name, st.ChildPID) + } + // Orphaned agent (host crashed): signal its process group directly. + if err := syscall.Kill(-st.ChildPID, syscall.SIGTERM); err != nil { + _ = syscall.Kill(st.ChildPID, syscall.SIGTERM) + } + return nil +} + // KillAll terminates every managed session. It replaces `tmux kill-server` // and is idempotent: an empty (or missing) runtime directory is success. func (c *Client) KillAll(ctx context.Context) error { diff --git a/internal/session/host.go b/internal/session/host.go index 971ac78..52ca71c 100644 --- a/internal/session/host.go +++ b/internal/session/host.go @@ -550,14 +550,18 @@ func (h *host) markClosed(exitCode int) { lastErr = err remaining := time.Until(deadline) if remaining <= 0 { - if lastErr != nil { - log.Warn("mark session closed failed after retry", "session", h.name, "error", lastErr) - } else { - log.Warn("mark session closed record not found before retry deadline", "session", h.name) - } + h.logMarkClosedFailure(lastErr) return } time.Sleep(min(delay, remaining)) delay = min(delay*2, markClosedRetryMax) } } + +func (h *host) logMarkClosedFailure(err error) { + if err != nil { + log.Warn("mark session closed failed after retry", "session", h.name, "error", err) + return + } + log.Warn("mark session closed record not found before retry deadline", "session", h.name) +} From 5b94e78a1f416054066e08a3de86a3b6160e210d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 12 Jul 2026 09:35:03 +0000 Subject: [PATCH 5/6] chore(security): document validated filesystem boundaries --- internal/session/session.go | 4 +++- internal/store/store.go | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index 7cd53b8..543d4c4 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -186,7 +186,9 @@ func readState(dir, name string) (State, error) { if !info.Mode().IsRegular() { return State{}, fmt.Errorf("session state %s is not a regular file", name) } - data, err := os.ReadFile(path) + // name has passed the strict canonical allow-list and dir has passed the + // owner-only identity check above, so path cannot escape the runtime dir. + data, err := os.ReadFile(path) // #nosec G304 -- validated name within a verified owner-only directory. if err != nil { return State{}, err } diff --git a/internal/store/store.go b/internal/store/store.go index 732d825..0d39ba0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -583,7 +583,9 @@ func (s *Store) saveNoLock(cfg Config) error { } func syncDir(dir string) error { - f, err := os.Open(dir) + // dir is the containing directory of Store.path; opening that configured + // path is the intended durability operation, not user-controlled inclusion. + f, err := os.Open(dir) // #nosec G304 -- Store intentionally fsyncs its configured parent directory. if err != nil { return fmt.Errorf("open store directory for sync: %w", err) } From 5810dea66cc3abbbf5a08d67d9cf8c8d20e386d5 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 12 Jul 2026 10:06:08 +0000 Subject: [PATCH 6/6] docs: define Windows support via WSL2 --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 02750eb..384e0cb 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,19 @@ there is nothing else to install. Providers are capability-probed at runtime. If a CLI is missing, `uam` hides it from the dispatch UI instead of failing the whole app. +## Supported platforms + +- Linux, including Ubuntu, on amd64 and arm64 +- macOS on Intel and Apple silicon +- Windows 10/11 through WSL2 with an Ubuntu distribution + +On Windows, install and run `uam` and the provider CLIs inside the same WSL2 +distribution. Sessions and paths then live inside that Linux environment. +Native Windows processes are not supported yet: the session host depends on a +Unix PTY, Unix process groups, and owner-only Unix runtime directories. A native +port requires a ConPTY and Job Object backend and will not be advertised until +its full create/list/attach/stop/restart lifecycle passes on Windows. + ## Install Install the `uam` binary directly: