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
69 changes: 54 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 49 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
46 changes: 16 additions & 30 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 5 additions & 16 deletions .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ name: SonarCloud
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
schedule:
- cron: "17 5 * * 3"

permissions:
contents: read
Expand All @@ -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
Expand All @@ -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 }}
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -189,9 +202,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
Expand Down
25 changes: 15 additions & 10 deletions internal/adapter/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<agent>-<id> 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 {
Expand All @@ -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
Expand Down
Loading
Loading