Skip to content
Open
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Kubernetes-native AI automation platform that orchestrates agentic sessions thro
- `components/runners/ambient-runner/` - Python runner executing Claude Code CLI in Job pods
- `components/ambient-cli/` - Go CLI (`acpctl`), manages agentic sessions from the command line
- `components/public-api/` - Stateless HTTP gateway, proxies to backend (no direct K8s access)
- `components/ambient-api-server/` - Go REST API microservice (rh-trex-ai framework), PostgreSQL-backed
- `components/ambient-api-server/` - Go REST API microservice (rh-trex-ai framework), PostgreSQL-backed. Each domain resource (agents, repo intelligences, etc.) is a self-contained plugin with model, DAO, service, handler, and migration
- `components/ambient-sdk/` - Go + Python client SDK for the platform's public REST API
- `components/open-webui-llm/` - Open WebUI LLM integration
- `components/manifests/` - Kustomize-based deployment manifests and overlays
Expand Down
4 changes: 2 additions & 2 deletions components/ambient-api-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ COPY pkg/ pkg/
COPY plugins/ plugins/
COPY openapi/ openapi/

# Build the binary
RUN go build -ldflags="-s -w" -o ambient-api-server ./cmd/ambient-api-server
# Build the binary (GOPROXY=direct works around HTTP/2 stream errors with proxy.golang.org inside Docker)
RUN GOPROXY=direct go build -ldflags="-s -w" -o ambient-api-server ./cmd/ambient-api-server

# Runtime stage
FROM registry.access.redhat.com/ubi9/ubi-minimal:latest
Expand Down
5 changes: 5 additions & 0 deletions components/ambient-api-server/cmd/ambient-api-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import (
_ "github.com/ambient-code/platform/components/ambient-api-server/plugins/roles"
_ "github.com/ambient-code/platform/components/ambient-api-server/plugins/sessions"
_ "github.com/ambient-code/platform/components/ambient-api-server/plugins/users"

// Project intelligence memory plugins
_ "github.com/ambient-code/platform/components/ambient-api-server/plugins/repoEvents"
_ "github.com/ambient-code/platform/components/ambient-api-server/plugins/repoFindings"
_ "github.com/ambient-code/platform/components/ambient-api-server/plugins/repoIntelligences"
)

func main() {
Expand Down
62 changes: 62 additions & 0 deletions components/ambient-api-server/plugins/repoEvents/dao.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package repoEvents

import (
"context"

"gorm.io/gorm/clause"

"github.com/openshift-online/rh-trex-ai/pkg/db"
)

type RepoEventDao interface {
Get(ctx context.Context, id string) (*RepoEvent, error)
Create(ctx context.Context, re *RepoEvent) (*RepoEvent, error)
FindByIDs(ctx context.Context, ids []string) (RepoEventList, error)
All(ctx context.Context) (RepoEventList, error)
}

var _ RepoEventDao = &sqlRepoEventDao{}

type sqlRepoEventDao struct {
sessionFactory *db.SessionFactory
}

func NewRepoEventDao(sessionFactory *db.SessionFactory) RepoEventDao {
return &sqlRepoEventDao{sessionFactory: sessionFactory}
}

func (d *sqlRepoEventDao) Get(ctx context.Context, id string) (*RepoEvent, error) {
g2 := (*d.sessionFactory).New(ctx)
var re RepoEvent
if err := g2.Take(&re, "id = ?", id).Error; err != nil {
return nil, err
}
return &re, nil
}

func (d *sqlRepoEventDao) Create(ctx context.Context, re *RepoEvent) (*RepoEvent, error) {
g2 := (*d.sessionFactory).New(ctx)
if err := g2.Omit(clause.Associations).Create(re).Error; err != nil {
db.MarkForRollback(ctx, err)
return nil, err
}
return re, nil
}

func (d *sqlRepoEventDao) FindByIDs(ctx context.Context, ids []string) (RepoEventList, error) {
g2 := (*d.sessionFactory).New(ctx)
items := RepoEventList{}
if err := g2.Where("id in (?)", ids).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}

func (d *sqlRepoEventDao) All(ctx context.Context) (RepoEventList, error) {
g2 := (*d.sessionFactory).New(ctx)
items := RepoEventList{}
if err := g2.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
72 changes: 72 additions & 0 deletions components/ambient-api-server/plugins/repoEvents/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package repoEvents

import (
"net/http"

"github.com/gorilla/mux"

"github.com/openshift-online/rh-trex-ai/pkg/errors"
"github.com/openshift-online/rh-trex-ai/pkg/handlers"
"github.com/openshift-online/rh-trex-ai/pkg/services"

"github.com/ambient-code/platform/components/ambient-api-server/plugins/common"
)

type repoEventHandler struct {
service RepoEventService
generic services.GenericService
}

func NewRepoEventHandler(svc RepoEventService, generic services.GenericService) *repoEventHandler {
return &repoEventHandler{
service: svc,
generic: generic,
}
}

func (h repoEventHandler) List(w http.ResponseWriter, r *http.Request) {
cfg := &handlers.HandlerConfig{
Action: func() (interface{}, *errors.ServiceError) {
ctx := r.Context()
listArgs := services.NewListArguments(r.URL.Query())

if serr := common.ApplyProjectScope(r, listArgs); serr != nil {
return nil, serr
}

var items []RepoEvent
paging, err := h.generic.List(ctx, "id", listArgs, &items)
if err != nil {
return nil, err
}

list := RepoEventListAPI{
Kind: "RepoEventList",
Page: int32(paging.Page),
Size: int32(paging.Size),
Total: int32(paging.Total),
Items: []RepoEventAPI{},
}
for _, item := range items {
list.Items = append(list.Items, PresentRepoEvent(&item))
}
return list, nil
},
}
handlers.HandleList(w, r, cfg)
}

func (h repoEventHandler) Get(w http.ResponseWriter, r *http.Request) {
cfg := &handlers.HandlerConfig{
Action: func() (interface{}, *errors.ServiceError) {
id := mux.Vars(r)["id"]
ctx := r.Context()
re, err := h.service.Get(ctx, id)
if err != nil {
return nil, err
}
return PresentRepoEvent(re), nil
Comment on lines +59 to +68
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scope Get by project before returning repo events.

List applies ApplyProjectScope, but Get fetches by ID only. Since RepoEventAPI exposes project/resource/actor details, this can leak cross-project events if a caller can guess or obtain an ID.

Suggested direction
 func (h repoEventHandler) Get(w http.ResponseWriter, r *http.Request) {
 	cfg := &handlers.HandlerConfig{
 		Action: func() (interface{}, *errors.ServiceError) {
 			id := mux.Vars(r)["id"]
 			ctx := r.Context()
 			re, err := h.service.Get(ctx, id)
 			if err != nil {
 				return nil, err
 			}
+			projectID := r.URL.Query().Get("project_id")
+			if projectID == "" {
+				projectID = r.Header.Get("X-Ambient-Project")
+			}
+			if projectID != "" && re.ProjectID != projectID {
+				return nil, errors.NotFound("RepoEvent", id)
+			}
 			return PresentRepoEvent(re), nil
 		},
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (h repoEventHandler) Get(w http.ResponseWriter, r *http.Request) {
cfg := &handlers.HandlerConfig{
Action: func() (interface{}, *errors.ServiceError) {
id := mux.Vars(r)["id"]
ctx := r.Context()
re, err := h.service.Get(ctx, id)
if err != nil {
return nil, err
}
return PresentRepoEvent(re), nil
func (h repoEventHandler) Get(w http.ResponseWriter, r *http.Request) {
cfg := &handlers.HandlerConfig{
Action: func() (interface{}, *errors.ServiceError) {
id := mux.Vars(r)["id"]
ctx := r.Context()
re, err := h.service.Get(ctx, id)
if err != nil {
return nil, err
}
projectID := r.URL.Query().Get("project_id")
if projectID == "" {
projectID = r.Header.Get("X-Ambient-Project")
}
if projectID != "" && re.ProjectID != projectID {
return nil, errors.NotFound("RepoEvent", id)
}
return PresentRepoEvent(re), nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/ambient-api-server/plugins/repoEvents/handler.go` around lines 59
- 68, Get currently returns a repo event by ID without ensuring it belongs to
the calling project, allowing cross-project leaks; modify repoEventHandler.Get
to enforce project scope by extracting the project identifier (from the request
context or mux.Vars) and either call a scoped service method (e.g.,
ApplyProjectScope/GetWithProject) or, after h.service.Get(ctx, id), validate
that the returned RepoEvent's project ID matches the request project ID and
return a not-found/forbidden ServiceError if it does not before calling
PresentRepoEvent.

},
}
handlers.HandleGet(w, r, cfg)
}
45 changes: 45 additions & 0 deletions components/ambient-api-server/plugins/repoEvents/migration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package repoEvents

import (
"gorm.io/gorm"

"github.com/go-gormigrate/gormigrate/v2"
"github.com/openshift-online/rh-trex-ai/pkg/db"
)

func migration() *gormigrate.Migration {
type RepoEvent struct {
db.Model
ResourceType string `gorm:"not null"`
ResourceID string `gorm:"not null"`
Action string `gorm:"not null"`
ActorType string `gorm:"not null"`
ActorID string `gorm:"not null"`
ProjectID string `gorm:"not null"`
Reason *string
Diff *string `gorm:"type:text"`
}

return &gormigrate.Migration{
ID: "202604091202",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&RepoEvent{}); err != nil {
return err
}
stmts := []string{
`CREATE INDEX IF NOT EXISTS idx_re_resource_type ON repo_events(resource_type)`,
`CREATE INDEX IF NOT EXISTS idx_re_resource_id ON repo_events(resource_id)`,
`CREATE INDEX IF NOT EXISTS idx_re_project_id ON repo_events(project_id)`,
}
for _, s := range stmts {
if err := tx.Exec(s).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("repo_events")
},
}
}
40 changes: 40 additions & 0 deletions components/ambient-api-server/plugins/repoEvents/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package repoEvents

import (
"github.com/openshift-online/rh-trex-ai/pkg/api"
"gorm.io/gorm"
)

type RepoEvent struct {
api.Meta

// What changed
ResourceType string `json:"resource_type" gorm:"not null;index"`
ResourceID string `json:"resource_id" gorm:"not null;index"`
Action string `json:"action" gorm:"not null"`

// Who
ActorType string `json:"actor_type" gorm:"not null"`
ActorID string `json:"actor_id" gorm:"not null"`

// Context
ProjectID string `json:"project_id" gorm:"not null;index"`
Reason *string `json:"reason,omitempty"`
Diff *string `json:"diff,omitempty" gorm:"type:text"`
}

type RepoEventList []*RepoEvent
type RepoEventIndex map[string]*RepoEvent

func (l RepoEventList) Index() RepoEventIndex {
index := RepoEventIndex{}
for _, o := range l {
index[o.ID] = o
}
return index
}

func (d *RepoEvent) BeforeCreate(tx *gorm.DB) error {
d.ID = api.NewID()
return nil
}
65 changes: 65 additions & 0 deletions components/ambient-api-server/plugins/repoEvents/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package repoEvents

import (
"net/http"

"github.com/gorilla/mux"
"github.com/openshift-online/rh-trex-ai/pkg/api/presenters"
"github.com/openshift-online/rh-trex-ai/pkg/auth"
"github.com/openshift-online/rh-trex-ai/pkg/db"
"github.com/openshift-online/rh-trex-ai/pkg/environments"
"github.com/openshift-online/rh-trex-ai/pkg/registry"
pkgserver "github.com/openshift-online/rh-trex-ai/pkg/server"
"github.com/openshift-online/rh-trex-ai/plugins/generic"

pkgrbac "github.com/ambient-code/platform/components/ambient-api-server/plugins/rbac"
)

type ServiceLocator func() RepoEventService

func NewServiceLocator(env *environments.Env) ServiceLocator {
return func() RepoEventService {
return NewRepoEventService(
NewRepoEventDao(&env.Database.SessionFactory),
)
}
}

func Service(s *environments.Services) RepoEventService {
if s == nil {
return nil
}
if obj := s.GetService("RepoEvents"); obj != nil {
locator := obj.(ServiceLocator)
return locator()
}
return nil
}

func init() {
registry.RegisterService("RepoEvents", func(env interface{}) interface{} {
return NewServiceLocator(env.(*environments.Env))
})

pkgserver.RegisterRoutes("repo_events", func(apiV1Router *mux.Router, services pkgserver.ServicesInterface, authMiddleware environments.JWTMiddleware, authzMiddleware auth.AuthorizationMiddleware) {
envServices := services.(*environments.Services)
if dbAuthz := pkgrbac.Middleware(envServices); dbAuthz != nil {
authzMiddleware = dbAuthz
}
svc := Service(envServices)
handler := NewRepoEventHandler(svc, generic.Service(envServices))

router := apiV1Router.PathPrefix("/repo_events").Subrouter()
router.HandleFunc("", handler.List).Methods(http.MethodGet)
router.HandleFunc("/{id}", handler.Get).Methods(http.MethodGet)
router.Use(authMiddleware.AuthenticateAccountJWT)
router.Use(authzMiddleware.AuthorizeApi)
})

presenters.RegisterPath(RepoEvent{}, "repo_events")
presenters.RegisterPath(&RepoEvent{}, "repo_events")
presenters.RegisterKind(RepoEvent{}, "RepoEvent")
presenters.RegisterKind(&RepoEvent{}, "RepoEvent")

db.RegisterMigration(migration())
}
54 changes: 54 additions & 0 deletions components/ambient-api-server/plugins/repoEvents/presenter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package repoEvents

import (
"time"

"github.com/openshift-online/rh-trex-ai/pkg/api/presenters"
)

type RepoEventAPI struct {
ID *string `json:"id,omitempty"`
Kind *string `json:"kind,omitempty"`
Href *string `json:"href,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`

ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
Action string `json:"action"`
ActorType string `json:"actor_type"`
ActorID string `json:"actor_id"`
ProjectID string `json:"project_id"`
Reason *string `json:"reason,omitempty"`
Diff *string `json:"diff,omitempty"`
}

type RepoEventListAPI struct {
Kind string `json:"kind"`
Page int32 `json:"page"`
Size int32 `json:"size"`
Total int32 `json:"total"`
Items []RepoEventAPI `json:"items"`
}

func ptrTime(v time.Time) *time.Time { return &v }

func PresentRepoEvent(re *RepoEvent) RepoEventAPI {
ref := presenters.PresentReference(re.ID, re)
return RepoEventAPI{
ID: ref.Id,
Kind: ref.Kind,
Href: ref.Href,
CreatedAt: ptrTime(re.CreatedAt),
UpdatedAt: ptrTime(re.UpdatedAt),

ResourceType: re.ResourceType,
ResourceID: re.ResourceID,
Action: re.Action,
ActorType: re.ActorType,
ActorID: re.ActorID,
ProjectID: re.ProjectID,
Reason: re.Reason,
Diff: re.Diff,
}
}
Loading