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
4 changes: 2 additions & 2 deletions .claude/rules/go-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ Applies to all Go files in the project.

## Go Module (`go.mod`) Conventions

- The `go` directive must not pin a non-zero patch version (`go 1.26.4` is
rejected by CI). `go 1.26` and `go 1.26.0` are both fine — a dependency's
- The `go` directive must not pin a non-zero patch version (`go 1.27.4` is
rejected by CI). `go 1.27` and `go 1.27.0` are both fine — a dependency's
own `go.mod` floor is always `X.Y.0`, never a later patch, so `X.Y.0` is
allowed to unblock a forced bump (e.g. picking up a CVE fix) without
reintroducing the patch-version churn this rule exists to prevent.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
${{ runner.os }}-go-build-

- name: Check go.mod version format
run: "! grep -qE '^go [0-9]+\\.[0-9]+\\.[1-9][0-9]*$' go.mod || { echo 'ERROR: go.mod must not pin a non-zero Go patch version (go 1.26 or go 1.26.0 are fine; a dependency floor is always X.Y.0, never a later patch)'; exit 1; }"
run: "! grep -qE '^go [0-9]+\\.[0-9]+\\.[1-9][0-9]*$' go.mod || { echo 'ERROR: go.mod must not pin a non-zero Go patch version (go 1.27 or go 1.27.0 are fine; a dependency floor is always X.Y.0, never a later patch)'; exit 1; }"

- name: Run golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
Expand Down
12 changes: 1 addition & 11 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,6 @@ jobs:
run: |
set -euo pipefail

# Ignored vulnerabilities with justification:
# Go stdlib advisories published 2026-06-02, all fixed in go1.26.4 /
# go1.25.11 (DoS / log-injection class, no RCE):
# GO-2026-5037 (CVE-2026-27145, crypto/x509 VerifyHostname)
# GO-2026-5038 (CVE-2026-42504, mime WordDecoder.DecodeHeader)
# GO-2026-5039 (CVE-2026-42507, net/textproto error messages)
# CI's `setup-go: stable` still resolves to go1.26.3 because the
# actions/go-versions manifest lags the Go release. Temporary
# exclusion; remove once CI builds on go1.26.4 or later.
#
# GO-2026-5932: golang.org/x/crypto/openpgp is deprecated-by-design
# ("unsafe, not maintained, should not be used"). No fixed version
# exists and none is planned.
Expand Down Expand Up @@ -170,7 +160,7 @@ jobs:
# REMOVAL TRIGGER: `go 1.27` sorts above `1.26.0`, so moving the
# go directive to 1.27 satisfies x/crypto v0.56.0 without a patch
# version. Bump x/crypto and drop both entries then.
IGNORED_VULNS="GO-2026-5037 GO-2026-5038 GO-2026-5039 GO-2026-5932 GO-2026-6354 GO-2026-6355"
IGNORED_VULNS="GO-2026-5932 GO-2026-6354 GO-2026-6355"

# Show the raw output for debugging
# A scan that produced nothing is a failed scan, not a clean one.
Expand Down
6 changes: 3 additions & 3 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ tasks:
platforms: [linux, darwin]
# we have to use ldflags to avoid the LC_DYSYMTAB linker error.
# https://github.com/stacklok/toolhive/issues/1687
- go test -ldflags=-extldflags=-Wl,-w -v -json -race $(go list ./... | grep -v '/test/e2e' | grep -v '/cmd/thv-operator/test-integration') | gotestfmt -hide "all"
- GODEBUG=gotestjsonbuildtext=1 go test -ldflags=-extldflags=-Wl,-w -v -json -race $(go list ./... | grep -v '/test/e2e' | grep -v '/cmd/thv-operator/test-integration') | gotestfmt -hide "all"

test-windows:
desc: Run unit tests (excluding e2e tests) on Windows with race detection
Expand Down Expand Up @@ -139,7 +139,7 @@ tasks:
platforms: [linux, darwin]
# we have to use ldflags to avoid the LC_DYSYMTAB linker error.
# https://github.com/stacklok/toolhive/issues/1687
- go test -ldflags=-extldflags=-Wl,-w -json -race -coverpkg=./... -coverprofile=coverage/coverage.out $(go list ./... | grep -v '/test/e2e' | grep -v '/cmd/thv-operator/test-integration') | gotestfmt -hide "all"
- GODEBUG=gotestjsonbuildtext=1 go test -ldflags=-extldflags=-Wl,-w -json -race -coverpkg=./... -coverprofile=coverage/coverage.out $(go list ./... | grep -v '/test/e2e' | grep -v '/cmd/thv-operator/test-integration') | gotestfmt -hide "all"
- go tool cover -func=coverage/coverage.out
- echo "Generating HTML coverage report in coverage/coverage.html"
- go tool cover -html=coverage/coverage.out -o coverage/coverage.html
Expand Down Expand Up @@ -204,7 +204,7 @@ tasks:
internal: true
cmds:
- which gotestfmt > /dev/null 2>&1 || go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest
- go test -ldflags=-extldflags=-Wl,-w -v -json -race -tags integration ./... | gotestfmt -hide "all"
- GODEBUG=gotestjsonbuildtext=1 go test -ldflags=-extldflags=-Wl,-w -v -json -race -tags integration ./... | gotestfmt -hide "all"

test-integration-windows:
desc: Run integration tests on Windows (requires Docker)
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ install Go and set up your development environment.

### Prerequisites

- **Go**: ToolHive requires Go 1.25. You can download and install Go from the
- **Go**: ToolHive requires Go 1.27. You can download and install Go from the
[official Go website](https://go.dev/doc/install).

- **Task** (Recommended): Install the [Task](https://taskfile.dev/) tool to run
Expand Down
2 changes: 1 addition & 1 deletion docs/arch/05-runconfig-and-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ The complete `RunConfig` struct is defined in `pkg/runner/config.go`.

**Fields:**
- `builder_image`: Override the default base image for the builder stage
- Go: Default `golang:1.26-alpine`
- Go: Default `golang:1.27-alpine`
- Node: Default `node:24-alpine`
- Python: Default `python:3.14-slim`
- `additional_packages`: Extra packages to install during the build and runtime stages (e.g., build tools, libraries)
Expand Down
4 changes: 2 additions & 2 deletions docs/runtime-version-customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This guide explains how to customize the base images and packages used when runn

When you use protocol schemes like `thv run go://github.com/example/server`, ToolHive automatically generates a container image. By default, it uses:

- **Go**: `golang:1.26-alpine` (builder), `alpine:3.23` (runtime)
- **Go**: `golang:1.27-alpine` (builder), `alpine:3.23` (runtime)
- **Node**: `node:24-alpine` (builder and runtime)
- **Python**: `python:3.14-slim` (builder and runtime)

Expand All @@ -28,7 +28,7 @@ Override the default base image for the builder stage.
**Examples:**

```bash
# Use Go 1.23 instead of default 1.26
# Use Go 1.23 instead of default 1.27
thv run go://github.com/example/server --runtime-image golang:1.23-alpine

# Use Node 20 LTS instead of default 22
Expand Down
2 changes: 1 addition & 1 deletion docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/stacklok/toolhive

go 1.26.0
go 1.27

require (
dario.cat/mergo v1.0.2
Expand Down
2 changes: 1 addition & 1 deletion pkg/authserver/server/handlers/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func TestLogClientLookupFailure(t *testing.T) {
},
{
name: "unresolvable CIMD client_id logs at Debug",
err: fmt.Errorf("%w: CIMD fetch failed: %w", fosite.ErrNotFound, errors.New("connection refused")),
err: fosite.ErrNotFound.WithHint("CIMD fetch failed").WithWrap(errors.New("connection refused")),
wantLevel: slog.LevelDebug,
},
{
Expand Down
20 changes: 6 additions & 14 deletions pkg/authserver/storage/cimd_decorator.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
// (see issue #6186).
slog.WarnContext(ctx, "CIMD document fetch failed",
"client_id", id, "error", err)
return nil, fmt.Errorf("%w: %w", fosite.ErrNotFound.WithHint("CIMD fetch failed"), err)
return nil, fosite.ErrNotFound.WithHint("CIMD fetch failed").WithWrap(err)
}

// Negotiate the effective token_endpoint_auth_method rather than rejecting
Expand All @@ -228,9 +228,8 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
if !ok {
slog.WarnContext(ctx, "CIMD client rejected: unsupported token_endpoint_auth_method",
"client_id", id, "token_endpoint_auth_method", doc.TokenEndpointAuthMethod)
return nil, fmt.Errorf("%w: CIMD document at %s claims token_endpoint_auth_method %q "+
return nil, fosite.ErrInvalidClient.WithHintf("CIMD document at %s claims token_endpoint_auth_method %q "+
"but this server only supports %q (token_endpoint_auth_methods_supported: %v)",
fosite.ErrInvalidClient.WithHint("unsupported token_endpoint_auth_method"),
id, doc.TokenEndpointAuthMethod, defaultCIMDTokenEndpointAuthMethod,
doc.TokenEndpointAuthMethodsSupported)
}
Expand All @@ -252,8 +251,7 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
if dcrErr != nil {
slog.WarnContext(ctx, "CIMD client rejected: invalid grant_types",
"client_id", id, "error", dcrErr.ErrorDescription)
return nil, fmt.Errorf("%w: CIMD document at %s: %s",
fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription)
return nil, fosite.ErrInvalidClient.WithHintf("CIMD document at %s: %s", id, dcrErr.ErrorDescription)
}
if len(grantTypes) < len(doc.GrantTypes) {
slog.Debug("CIMD: ignoring grant_types this server does not support",
Expand All @@ -263,8 +261,7 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
if dcrErr != nil {
slog.WarnContext(ctx, "CIMD client rejected: invalid response_types",
"client_id", id, "error", dcrErr.ErrorDescription)
return nil, fmt.Errorf("%w: CIMD document at %s: %s",
fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription)
return nil, fosite.ErrInvalidClient.WithHintf("CIMD document at %s: %s", id, dcrErr.ErrorDescription)
}
if len(responseTypes) < len(doc.ResponseTypes) {
slog.Debug("CIMD: ignoring response_types this server does not support",
Expand Down Expand Up @@ -336,8 +333,7 @@ func (d *CIMDStorageDecorator) resolveScopes(
if dcrErr != nil {
slog.WarnContext(ctx, "CIMD client rejected: invalid scope",
"client_id", id, "error", dcrErr.ErrorDescription)
return nil, fmt.Errorf("%w: CIMD document at %s: %s",
fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription)
return nil, fosite.ErrInvalidClient.WithHintf("CIMD document at %s: %s", id, dcrErr.ErrorDescription)
}
resolvedScopes = computed
} else {
Expand All @@ -347,11 +343,7 @@ func (d *CIMDStorageDecorator) resolveScopes(
if dcrErr != nil {
slog.WarnContext(ctx, "CIMD client rejected: no usable default scopes",
"client_id", id, "error", dcrErr.ErrorDescription)
return nil, fmt.Errorf("%w: CIMD document at %s omits scope and "+
"none of the default scopes are supported by this server — "+
"the document must explicitly declare its required scopes",
fosite.ErrInvalidClient.WithHint("scope field required"),
id)
return nil, fosite.ErrInvalidClient.WithHint("scope field required")
}
resolvedScopes = computed
droppedDefaults = dropped
Expand Down
30 changes: 15 additions & 15 deletions pkg/authserver/storage/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ func (s *MemoryStorage) GetClient(_ context.Context, id string) (fosite.Client,
client, ok := s.clients[id]
if !ok {
slog.Debug("client not found", "client_id", id)
return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Client not found"))
return nil, notFoundRFC6749Error("Client not found")
}
return client, nil
}
Expand Down Expand Up @@ -751,7 +751,7 @@ func (s *MemoryStorage) GetAuthorizeCodeSession(_ context.Context, code string,
entry, ok := s.authCodes[code]
if !ok {
slog.Debug("authorization code not found")
return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Authorization code not found"))
return nil, notFoundRFC6749Error("Authorization code not found")
}

// Check if the code has been invalidated
Expand All @@ -771,7 +771,7 @@ func (s *MemoryStorage) InvalidateAuthorizeCodeSession(_ context.Context, code s

if _, ok := s.authCodes[code]; !ok {
slog.Debug("authorization code not found for invalidation")
return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Authorization code not found"))
return notFoundRFC6749Error("Authorization code not found")
}

now := time.Now()
Expand Down Expand Up @@ -822,7 +822,7 @@ func (s *MemoryStorage) GetAccessTokenSession(_ context.Context, signature strin
entry, ok := s.accessTokens[signature]
if !ok {
slog.Debug("access token not found")
return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Access token not found"))
return nil, notFoundRFC6749Error("Access token not found")
}
return entry.value, nil
}
Expand All @@ -833,7 +833,7 @@ func (s *MemoryStorage) DeleteAccessTokenSession(_ context.Context, signature st
defer s.mu.Unlock()

if _, ok := s.accessTokens[signature]; !ok {
return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Access token not found"))
return notFoundRFC6749Error("Access token not found")
}
delete(s.accessTokens, signature)
return nil
Expand Down Expand Up @@ -877,7 +877,7 @@ func (s *MemoryStorage) GetRefreshTokenSession(_ context.Context, signature stri
entry, ok := s.refreshTokens[signature]
if !ok {
slog.Debug("refresh token not found")
return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Refresh token not found"))
return nil, notFoundRFC6749Error("Refresh token not found")
}
return entry.value, nil
}
Expand All @@ -888,7 +888,7 @@ func (s *MemoryStorage) DeleteRefreshTokenSession(_ context.Context, signature s
defer s.mu.Unlock()

if _, ok := s.refreshTokens[signature]; !ok {
return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Refresh token not found"))
return notFoundRFC6749Error("Refresh token not found")
}
delete(s.refreshTokens, signature)
return nil
Expand Down Expand Up @@ -1005,7 +1005,7 @@ func (s *MemoryStorage) GetPKCERequestSession(_ context.Context, signature strin
entry, ok := s.pkceRequests[signature]
if !ok {
slog.Debug("pkce request not found")
return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("PKCE request not found"))
return nil, notFoundRFC6749Error("PKCE request not found")
}
return entry.value, nil
}
Expand All @@ -1016,7 +1016,7 @@ func (s *MemoryStorage) DeletePKCERequestSession(_ context.Context, signature st
defer s.mu.Unlock()

if _, ok := s.pkceRequests[signature]; !ok {
return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("PKCE request not found"))
return notFoundRFC6749Error("PKCE request not found")
}
delete(s.pkceRequests, signature)
return nil
Expand Down Expand Up @@ -1147,7 +1147,7 @@ func (s *MemoryStorage) GetUpstreamTokens(_ context.Context, sessionID, provider
entry, ok := s.upstreamTokens[upstreamKey{sessionID, providerName}]
if !ok {
slog.Debug("upstream tokens not found", "session_id", sessionID, "provider_name", providerName)
return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found"))
return nil, notFoundRFC6749Error("Upstream tokens not found")
}

// Return a defensive copy to prevent aliasing issues
Expand Down Expand Up @@ -1200,7 +1200,7 @@ func (s *MemoryStorage) DeleteUpstreamTokens(_ context.Context, sessionID string
}
}
if !found {
return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found"))
return notFoundRFC6749Error("Upstream tokens not found")
}
return nil
}
Expand Down Expand Up @@ -1266,7 +1266,7 @@ func (s *MemoryStorage) GetLatestUpstreamTokensForUser(_ context.Context, userID
}

if winner == nil {
return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found"))
return nil, notFoundRFC6749Error("Upstream tokens not found")
}

return cloneUpstreamTokens(winner), nil
Expand Down Expand Up @@ -1331,7 +1331,7 @@ func (s *MemoryStorage) LoadPendingAuthorization(_ context.Context, state string
entry, ok := s.pendingAuthorizations[state]
if !ok {
slog.Debug("pending authorization not found")
return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Pending authorization not found"))
return nil, notFoundRFC6749Error("Pending authorization not found")
}

// Check if expired
Expand Down Expand Up @@ -1372,7 +1372,7 @@ func (s *MemoryStorage) DeletePendingAuthorization(_ context.Context, state stri
defer s.mu.Unlock()

if _, ok := s.pendingAuthorizations[state]; !ok {
return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Pending authorization not found"))
return notFoundRFC6749Error("Pending authorization not found")
}
delete(s.pendingAuthorizations, state)
return nil
Expand Down Expand Up @@ -1646,7 +1646,7 @@ func (s *MemoryStorage) GetDCRCredentials(_ context.Context, key DCRKey) (*DCRCr
"upstream_id", key.UpstreamID,
"redirect_uri", key.RedirectURI,
)
return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("DCR credentials not found"))
return nil, notFoundRFC6749Error("DCR credentials not found")
}

return cloneDCRCredentials(entry), nil
Expand Down
Loading
Loading