Skip to content

Commit 594f25f

Browse files
committed
fix: honor profile in operator tool catalog
1 parent 23b1626 commit 594f25f

10 files changed

Lines changed: 6861 additions & 5836 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
id: ET-profile-scoped-operator-tool-catalog
3+
area: ET
4+
title: Project operator tools in the selected Profile and Workspace
5+
persona: Bruno
6+
journey: J-run-extension-commands
7+
expected: Operator tool list, search, info, approval, invoke, and toolset reads resolve the selected Profile; an extension tool appears only in its owning Profile and Workspace, while default and peer scopes remain isolated and denied calls preserve structured permission errors.
8+
entry_points: compozy --profile <name> tool list|search|info|approve|invoke; GET|POST /api/tools; GET /api/toolsets
9+
qa_status: untested
10+
bug_ids:
11+
fix_status:
12+
retest_status:
13+
fix_commits:
14+
evidence:
15+
last_report:
16+
overlaps: ET-compozy-native-tool-invocation; LP-extension-action-schema-scope
17+
---
18+
19+
QA impact 2026-09-04: added for the Profile selector regression on operator tool and toolset projections. A focused isolated runtime walk must prove the owning Profile/Workspace, default and peer isolation, and structured permission denial before this scenario can pass.

‎internal/api/core/tools.go‎

Lines changed: 27 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"strings"
88

99
"github.com/compozy/compozy/internal/api/contract"
10-
"github.com/compozy/compozy/internal/store"
1110
toolspkg "github.com/compozy/compozy/internal/tools"
1211
"github.com/gin-gonic/gin"
1312
)
@@ -25,7 +24,11 @@ func (h *BaseHandlers) ListTools(c *gin.Context) {
2524
h.respondError(c, http.StatusServiceUnavailable, errors.New("tool registry is not configured"))
2625
return
2726
}
28-
scope := h.operatorToolScope(c)
27+
scope, err := h.resolveOperatorToolScope(c)
28+
if err != nil {
29+
h.respondProfileReadScopeError(c, err)
30+
return
31+
}
2932
views, err := h.Tools.List(c.Request.Context(), scope)
3033
if err != nil {
3134
h.respondToolError(c, err)
@@ -44,7 +47,12 @@ func (h *BaseHandlers) SearchTools(c *gin.Context) {
4447
h.respondError(c, http.StatusServiceUnavailable, errors.New("tool registry is not configured"))
4548
return
4649
}
47-
scope := toolScopeFromSearch(h.operatorToolScope(c), req)
50+
scope, err := h.resolveOperatorToolScope(c)
51+
if err != nil {
52+
h.respondProfileReadScopeError(c, err)
53+
return
54+
}
55+
scope = toolScopeFromSearch(scope, req)
4856
views, err := h.Tools.Search(c.Request.Context(), scope, toolspkg.SearchQuery{
4957
Query: req.Query,
5058
Limit: req.Limit,
@@ -66,7 +74,12 @@ func (h *BaseHandlers) GetTool(c *gin.Context) {
6674
if !ok {
6775
return
6876
}
69-
view, err := h.Tools.Get(c.Request.Context(), h.operatorToolScope(c), id)
77+
scope, err := h.resolveOperatorToolScope(c)
78+
if err != nil {
79+
h.respondProfileReadScopeError(c, err)
80+
return
81+
}
82+
view, err := h.Tools.Get(c.Request.Context(), scope, id)
7083
if err != nil {
7184
h.respondToolError(c, err)
7285
return
@@ -95,7 +108,11 @@ func (h *BaseHandlers) CreateToolApproval(c *gin.Context) {
95108
))
96109
return
97110
}
98-
scope := h.operatorToolScope(c)
111+
scope, err := h.resolveOperatorToolScope(c)
112+
if err != nil {
113+
h.respondProfileReadScopeError(c, err)
114+
return
115+
}
99116
effectiveScope, err := approvalScopeFromRequest(scope, req)
100117
if err != nil {
101118
h.respondToolError(c, err)
@@ -147,7 +164,11 @@ func (h *BaseHandlers) InvokeTool(c *gin.Context) {
147164
))
148165
return
149166
}
150-
scope := h.operatorToolScope(c)
167+
scope, err := h.resolveOperatorToolScope(c)
168+
if err != nil {
169+
h.respondProfileReadScopeError(c, err)
170+
return
171+
}
151172
scope.SessionID = firstNonEmpty(req.SessionID, scope.SessionID)
152173
scope.WorkspaceID = firstNonEmpty(req.WorkspaceID, scope.WorkspaceID)
153174
scope.AgentName = firstNonEmpty(req.AgentName, scope.AgentName)
@@ -234,39 +255,6 @@ func (h *BaseHandlers) SearchSessionTools(c *gin.Context) {
234255
c.JSON(http.StatusOK, contract.ToolsResponse{Tools: ToolPayloadsFromViews(views)})
235256
}
236257

237-
// ListToolsets returns named toolsets with expansion diagnostics.
238-
func (h *BaseHandlers) ListToolsets(c *gin.Context) {
239-
if h.Toolsets == nil {
240-
h.respondError(c, http.StatusServiceUnavailable, errors.New("toolset registry is not configured"))
241-
return
242-
}
243-
views, err := h.Toolsets.ListToolsets(c.Request.Context(), h.operatorToolScope(c))
244-
if err != nil {
245-
h.respondToolError(c, err)
246-
return
247-
}
248-
c.JSON(http.StatusOK, contract.ToolsetsResponse{Toolsets: ToolsetPayloadsFromViews(views)})
249-
}
250-
251-
// GetToolset returns one named toolset with expansion diagnostics.
252-
func (h *BaseHandlers) GetToolset(c *gin.Context) {
253-
if h.Toolsets == nil {
254-
h.respondError(c, http.StatusServiceUnavailable, errors.New("toolset registry is not configured"))
255-
return
256-
}
257-
id := toolspkg.ToolsetID(strings.TrimSpace(c.Param("id")))
258-
if err := id.Validate(); err != nil {
259-
h.respondToolError(c, err)
260-
return
261-
}
262-
view, err := h.Toolsets.GetToolset(c.Request.Context(), h.operatorToolScope(c), id)
263-
if err != nil {
264-
h.respondToolError(c, err)
265-
return
266-
}
267-
c.JSON(http.StatusOK, contract.ToolsetResponse{Toolset: ToolsetPayloadFromView(view)})
268-
}
269-
270258
// ToolPayloadsFromViews converts registry views into public DTOs.
271259
func ToolPayloadsFromViews(views []toolspkg.ToolView) []contract.ToolPayload {
272260
payloads := make([]contract.ToolPayload, 0, len(views))
@@ -423,17 +411,6 @@ func (h *BaseHandlers) toolIDParam(c *gin.Context) (toolspkg.ToolID, bool) {
423411
return id, true
424412
}
425413

426-
// operatorToolScope builds privileged projections from query parameters only.
427-
func (h *BaseHandlers) operatorToolScope(c *gin.Context) toolspkg.Scope {
428-
return toolspkg.Scope{
429-
ProfileID: store.DefaultProfileID,
430-
WorkspaceID: strings.TrimSpace(firstNonEmpty(c.Query("workspace_id"), c.Query("workspace"))),
431-
SessionID: strings.TrimSpace(c.Query("session_id")),
432-
AgentName: strings.TrimSpace(c.Query("agent_name")),
433-
Operator: true,
434-
}
435-
}
436-
437414
// sessionToolScope anchors session projections to the resolved route workspace and session IDs.
438415
func (h *BaseHandlers) sessionToolScope(c *gin.Context, workspaceID string, sessionID string) toolspkg.Scope {
439416
return toolspkg.Scope{
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package core
2+
3+
import (
4+
"strings"
5+
6+
profilepkg "github.com/compozy/compozy/internal/profile"
7+
toolspkg "github.com/compozy/compozy/internal/tools"
8+
"github.com/gin-gonic/gin"
9+
)
10+
11+
// resolveOperatorToolScope builds a privileged projection in the selected profile.
12+
func (h *BaseHandlers) resolveOperatorToolScope(c *gin.Context) (toolspkg.Scope, error) {
13+
selection, err := h.resolveProfileReadSelection(c)
14+
if err != nil {
15+
return toolspkg.Scope{}, err
16+
}
17+
if selection.Scope.AllProfiles {
18+
return toolspkg.Scope{}, &profilepkg.Error{
19+
Code: profileSelectionConflictCode,
20+
Message: "tools require exactly one profile",
21+
Action: "choose the profile that owns the tool catalog",
22+
Cause: profilepkg.ErrInvalidInput,
23+
}
24+
}
25+
return toolspkg.Scope{
26+
ProfileID: strings.TrimSpace(selection.Scope.ProfileID),
27+
WorkspaceID: strings.TrimSpace(firstNonEmpty(c.Query("workspace_id"), c.Query("workspace"))),
28+
SessionID: strings.TrimSpace(c.Query("session_id")),
29+
AgentName: strings.TrimSpace(c.Query("agent_name")),
30+
Operator: true,
31+
}, nil
32+
}

0 commit comments

Comments
 (0)