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
3 changes: 3 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@ func main() {
agentStore := store.NewAgentStore(db)
runStore := store.NewRunStore(db)
agentSvc := service.NewAgentService(agentStore, userStore)
// Agent prefs are partitioned by user, so the admin panel's "how many
// people have their own settings" and its reset both walk the roster.
agentSvc.SetUserLister(userStore)
orchestrator := service.NewOrchestrator(runStore, agentSvc, userStore, messageSvc, redisPubSub, jwtMgr)
messageSvc.SetAgentDispatcher(orchestrator)
// Deleting a chat sweeps its agent-run activity logs (a thread root sweeps
Expand Down
94 changes: 81 additions & 13 deletions internal/handler/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ type agentView struct {
// page pre-fills the editor with the effective prompt and needs this to
// know when an edit lands back ON the default (→ store "inherit").
DefaultPersona string `json:"defaultPersona"`
// DefaultHarness/DefaultModel/DefaultExecutionMode are the template's
// engine, for the same reason: the admin panel edits the WORKSPACE
// DEFAULT, and `resolved` is the caller's own once they have prefs — an
// admin who had customised an agent would otherwise be shown their own
// values labelled as everyone's.
DefaultHarness string `json:"defaultHarness"`
DefaultModel string `json:"defaultModel"`
DefaultExecutionMode string `json:"defaultExecutionMode"`
}

func (h *AgentHandler) view(r *http.Request, agent *model.User, callerID string) (agentView, error) {
Expand Down Expand Up @@ -90,13 +98,16 @@ func (h *AgentHandler) view(r *http.Request, agent *model.User, callerID string)
}
}
return agentView{
ID: agent.ID,
DisplayName: agent.DisplayName,
Slug: slug,
Status: status,
Prefs: prefs,
Resolved: resolved,
DefaultPersona: tpl.Persona,
ID: agent.ID,
DisplayName: agent.DisplayName,
Slug: slug,
Status: status,
Prefs: prefs,
Resolved: resolved,
DefaultPersona: tpl.Persona,
DefaultHarness: tpl.Harness,
DefaultModel: tpl.Model,
DefaultExecutionMode: tpl.ExecutionMode,
}, nil
}

Expand Down Expand Up @@ -149,13 +160,16 @@ func (h *AgentHandler) RenameAgent(w http.ResponseWriter, r *http.Request) {
Harness string `json:"harness"`
Model string `json:"model"`
ExecutionMode string `json:"executionMode"`
// Template prompt. Blank = unchanged; the service refuses a blank
// prompt outright, so there is no "clear it" here by design.
Persona string `json:"persona"`
}
if err := readAgentJSON(r, &body, maxAgentBodyBytes); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
if body.DisplayName == "" && body.SkillIDs == nil && body.Harness == "" {
writeError(w, http.StatusBadRequest, "bad_request", "nothing to update — set displayName, skillIDs and/or harness")
if body.DisplayName == "" && body.SkillIDs == nil && body.Harness == "" && body.Persona == "" {
writeError(w, http.StatusBadRequest, "bad_request", "nothing to update — set displayName, skillIDs, harness and/or persona")
return
}
if body.Harness == "" && (body.Model != "" || body.ExecutionMode != "") {
Expand Down Expand Up @@ -194,6 +208,12 @@ func (h *AgentHandler) RenameAgent(w http.ResponseWriter, r *http.Request) {
return
}
}
if body.Persona != "" {
if tpl, err = h.agents.SetAgentPersona(r.Context(), r.PathValue("slug"), body.Persona); err != nil {
fail(err, "set agent prompt")
return
}
}
writeJSON(w, http.StatusOK, JSON{"agent": tpl})
}

Expand Down Expand Up @@ -638,7 +658,15 @@ func (h *AgentHandler) CreateSkill(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, JSON{"skill": sk})
}

// UpdateSkill applies the author's edits.
// callerIsAdmin reports whether the request's signed claims carry the admin
// system role. Used where authority WIDENS a caller's reach inside a handler
// the middleware has already let through, rather than gating the route.
func callerIsAdmin(r *http.Request) bool {
claims := middleware.ClaimsFromContext(r.Context())
return claims != nil && claims.SystemRole == model.SystemRoleAdmin
}

// UpdateSkill applies the author's edits — or an admin's.
// PATCH /api/v1/skills/{id}
func (h *AgentHandler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
callerID := middleware.UserIDFromContext(r.Context())
Expand All @@ -647,25 +675,65 @@ func (h *AgentHandler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
sk, err := h.agents.UpdateSkill(r.Context(), callerID, r.PathValue("id"), patch)
sk, err := h.agents.UpdateSkill(r.Context(), callerID, callerIsAdmin(r), r.PathValue("id"), patch)
if err != nil {
h.writeSkillError(w, err)
return
}
writeJSON(w, http.StatusOK, JSON{"skill": sk})
}

// DeleteSkill removes a skill (author-only).
// DeleteSkill removes a skill (author, or any admin).
// DELETE /api/v1/skills/{id}
func (h *AgentHandler) DeleteSkill(w http.ResponseWriter, r *http.Request) {
callerID := middleware.UserIDFromContext(r.Context())
if err := h.agents.DeleteSkill(r.Context(), callerID, r.PathValue("id")); err != nil {
if err := h.agents.DeleteSkill(r.Context(), callerID, callerIsAdmin(r), r.PathValue("id")); err != nil {
h.writeSkillError(w, err)
return
}
writeJSON(w, http.StatusOK, JSON{"ok": true})
}

// CountOverrides reports how many people have their own settings for an
// agent, so an admin editing the workspace default can see how far it reaches.
// GET /api/v1/agents/{slug}/overrides
func (h *AgentHandler) CountOverrides(w http.ResponseWriter, r *http.Request) {
n, err := h.agents.CountAgentOverrides(r.Context(), r.PathValue("slug"))
if err != nil {
h.writeOverrideError(w, err, "count overrides")
return
}
writeJSON(w, http.StatusOK, JSON{"count": n})
}

// ResetOverrides clears everyone's personal settings for an agent so the
// template governs the whole workspace.
//
// Destructive and not undoable: a prefs row is one document, so this also
// clears each person's persona, limits, follow-up settings and pre-approved
// tool classes for this agent — not only the harness and model. The count
// comes back so the caller can report what it actually removed.
// DELETE /api/v1/agents/{slug}/overrides
func (h *AgentHandler) ResetOverrides(w http.ResponseWriter, r *http.Request) {
n, err := h.agents.ResetAgentOverrides(r.Context(), r.PathValue("slug"))
if err != nil {
h.writeOverrideError(w, err, "reset overrides")
return
}
writeJSON(w, http.StatusOK, JSON{"cleared": n})
}

func (h *AgentHandler) writeOverrideError(w http.ResponseWriter, err error, what string) {
switch {
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "unknown agent")
case errors.Is(err, service.ErrValidation):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
writeError(w, http.StatusInternalServerError, "internal", "failed to "+what)
}
}

func (h *AgentHandler) writeSkillError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
Expand Down
90 changes: 89 additions & 1 deletion internal/handler/agent_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ func (d *hagentCovDir) PutAgentPrefs(_ context.Context, prefs *model.UserAgentPr
return nil
}

func (d *hagentCovDir) DeleteAgentPrefs(_ context.Context, userID, slug string) error {
if err := d.trip("DeleteAgentPrefs"); err != nil {
return err
}
delete(d.prefs, userID+"|"+slug)
return nil
}

func (d *hagentCovDir) GetAgentPrefs(_ context.Context, userID, slug string) (*model.UserAgentPrefs, error) {
if err := d.trip("GetAgentPrefs"); err != nil {
return nil, err
Expand Down Expand Up @@ -591,6 +599,7 @@ type hagentCovEnv struct {
msgs *hagentCovMessages
agentID string
h *AgentHandler
svc *service.AgentService
}

// hagentCovNewEnv builds a handler over real services with one seeded shared
Expand All @@ -616,7 +625,7 @@ func hagentCovNewEnv() *hagentCovEnv {
jwtMgr := auth.NewJWTManager("hagent-cov-secret", 15*time.Minute, 720*time.Hour)
orch := service.NewOrchestrator(runs, agentSvc, &hagentCovOrchUsers{users: users}, msgs, hagentCovPub{}, jwtMgr)
h := NewAgentHandler(agentSvc, orch, userSvc, jwtMgr)
return &hagentCovEnv{dir: dir, runs: runs, users: users, msgs: msgs, agentID: agentID, h: h}
return &hagentCovEnv{dir: dir, runs: runs, users: users, msgs: msgs, agentID: agentID, h: h, svc: agentSvc}
}

// seedRun stores a run both by ID and in its parent's listing.
Expand Down Expand Up @@ -1346,3 +1355,82 @@ func TestHagentCovDeleteSkill(t *testing.T) {
rec = hagentCovDo(env.h.DeleteSkill, hagentCovReq(http.MethodDelete, "/api/v1/skills/sk1", "", "u1", map[string]string{"id": "sk1"}))
hagentCovWant(t, rec, http.StatusOK)
}

// hagentCovRoster is a one-page user lister for the override walk.
type hagentCovRoster struct {
users []*model.User
err error
}

func (r *hagentCovRoster) ListUsers(_ context.Context, _ int, _ string) ([]*model.User, string, error) {
return r.users, "", r.err
}

func TestHagentCovOverrides(t *testing.T) {
env := hagentCovNewEnv()
slug := map[string]string{"slug": "gg"}

// No roster wired: a validation error, not a 500 — the deployment is
// misconfigured, which is a different thing from the request being wrong.
rec := hagentCovDo(env.h.CountOverrides, hagentCovReq(http.MethodGet, "/api/v1/agents/gg/overrides", "", "u1", slug))
hagentCovWant(t, rec, http.StatusBadRequest)

env.svc.SetUserLister(&hagentCovRoster{users: []*model.User{
{ID: "u1", DisplayName: "u1"},
{ID: "u2", DisplayName: "u2"},
}})

// An unknown agent is a 404, never a confident zero.
rec = hagentCovDo(env.h.CountOverrides, hagentCovReq(http.MethodGet, "/api/v1/agents/ghost/overrides", "", "u1", map[string]string{"slug": "ghost"}))
hagentCovWant(t, rec, http.StatusNotFound)

// Nobody has customized gg yet.
rec = hagentCovDo(env.h.CountOverrides, hagentCovReq(http.MethodGet, "/api/v1/agents/gg/overrides", "", "u1", slug))
hagentCovWant(t, rec, http.StatusOK)
if got := hagentCovJSON(t, rec)["count"]; got != float64(0) {
t.Fatalf("count = %v, want 0", got)
}

// u2 customizes, so the count moves and the reset clears exactly that row.
if err := env.dir.PutAgentPrefs(context.Background(), &model.UserAgentPrefs{
UserID: "u2", Slug: "gg", Harness: model.HarnessBedrock,
}); err != nil {
t.Fatalf("seed prefs: %v", err)
}
rec = hagentCovDo(env.h.CountOverrides, hagentCovReq(http.MethodGet, "/api/v1/agents/gg/overrides", "", "u1", slug))
hagentCovWant(t, rec, http.StatusOK)
if got := hagentCovJSON(t, rec)["count"]; got != float64(1) {
t.Fatalf("count = %v, want 1", got)
}

rec = hagentCovDo(env.h.ResetOverrides, hagentCovReq(http.MethodDelete, "/api/v1/agents/gg/overrides", "", "u1", slug))
hagentCovWant(t, rec, http.StatusOK)
if got := hagentCovJSON(t, rec)["cleared"]; got != float64(1) {
t.Fatalf("cleared = %v, want 1", got)
}

// A store failure is a 500 on both verbs.
env.dir.failFrom["GetAgentPrefs"] = 1
rec = hagentCovDo(env.h.CountOverrides, hagentCovReq(http.MethodGet, "/api/v1/agents/gg/overrides", "", "u1", slug))
hagentCovWant(t, rec, http.StatusInternalServerError)
rec = hagentCovDo(env.h.ResetOverrides, hagentCovReq(http.MethodDelete, "/api/v1/agents/gg/overrides", "", "u1", slug))
hagentCovWant(t, rec, http.StatusInternalServerError)
delete(env.dir.failFrom, "GetAgentPrefs")
}

// callerIsAdmin reads the SIGNED claims, not a body field — a member must not
// be able to widen their own reach over someone else's skill.
func TestHagentCovCallerIsAdmin(t *testing.T) {
req := httptest.NewRequest(http.MethodPatch, "/api/v1/skills/s1", nil)
if callerIsAdmin(req) {
t.Fatal("no claims must not read as admin")
}
req = req.WithContext(middleware.ContextWithClaims(req.Context(), &model.TokenClaims{UserID: "u1", SystemRole: model.SystemRoleMember}))
if callerIsAdmin(req) {
t.Fatal("member must not read as admin")
}
req = req.WithContext(middleware.ContextWithClaims(req.Context(), &model.TokenClaims{UserID: "u1", SystemRole: model.SystemRoleAdmin}))
if !callerIsAdmin(req) {
t.Fatal("admin claims must read as admin")
}
}
1 change: 1 addition & 0 deletions internal/handler/agentrunner_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ func (d *hrunnerCovDir) ListTemplates(context.Context) ([]*model.AgentTemplate,

func (d *hrunnerCovDir) CreateAgentUser(context.Context, *model.User) error { return nil }
func (d *hrunnerCovDir) PutAgentPrefs(context.Context, *model.UserAgentPrefs) error { return nil }
func (d *hrunnerCovDir) DeleteAgentPrefs(context.Context, string, string) error { return nil }
func (d *hrunnerCovDir) GetAgentPrefs(context.Context, string, string) (*model.UserAgentPrefs, error) {
return nil, store.ErrNotFound
}
Expand Down
1 change: 1 addition & 0 deletions internal/handler/agentworkspace_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ func (hwsCovAgentDir) ListTemplates(context.Context) ([]*model.AgentTemplate, er
}
func (hwsCovAgentDir) CreateAgentUser(context.Context, *model.User) error { return nil }
func (hwsCovAgentDir) PutAgentPrefs(context.Context, *model.UserAgentPrefs) error { return nil }
func (hwsCovAgentDir) DeleteAgentPrefs(context.Context, string, string) error { return nil }
func (hwsCovAgentDir) GetAgentPrefs(context.Context, string, string) (*model.UserAgentPrefs, error) {
return nil, store.ErrNotFound
}
Expand Down
3 changes: 3 additions & 0 deletions internal/handler/codingtask_context_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,9 @@ func (f *htaskCovAgentDir) CreateAgentUser(context.Context, *model.User) error {
func (f *htaskCovAgentDir) PutAgentPrefs(context.Context, *model.UserAgentPrefs) error {
return nil
}
func (f *htaskCovAgentDir) DeleteAgentPrefs(context.Context, string, string) error {
return nil
}
func (f *htaskCovAgentDir) GetAgentPrefs(context.Context, string, string) (*model.UserAgentPrefs, error) {
return nil, store.ErrNotFound
}
Expand Down
2 changes: 2 additions & 0 deletions internal/handler/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ func NewRouter(d *Deps) http.Handler {
mux.Handle("POST /api/v1/agents", middleware.WrapFunc(d.Agent.CreateAgent, authMW, middleware.RequireSystemRole(model.SystemRoleAdmin), writeLimit))
mux.Handle("PATCH /api/v1/agents/{slug}", middleware.WrapFunc(d.Agent.RenameAgent, authMW, middleware.RequireSystemRole(model.SystemRoleAdmin), writeLimit))
mux.Handle("PATCH /api/v1/agents/{slug}/prefs", middleware.WrapFunc(d.Agent.UpdatePrefs, authMW))
mux.Handle("GET /api/v1/agents/{slug}/overrides", middleware.WrapFunc(d.Agent.CountOverrides, authMW, middleware.RequireSystemRole(model.SystemRoleAdmin)))
mux.Handle("DELETE /api/v1/agents/{slug}/overrides", middleware.WrapFunc(d.Agent.ResetOverrides, authMW, middleware.RequireSystemRole(model.SystemRoleAdmin), writeLimit))
// Rate-limited like every other write: minting is cheap for the caller
// and signs a long-lived credential, so it must not be the one POST a
// client can hammer freely.
Expand Down
Loading
Loading