feat(server): distinguish agent vs human CLI actions (#181)
* feat(server): distinguish agent vs human CLI actions via X-Agent-ID/X-Task-ID headers Extract resolveActor helper in handler to centralize agent identity resolution from X-Agent-ID header with X-Task-ID cross-validation. Fix DeleteComment, DeleteIssue, and UpdateComment handlers that previously hardcoded "member" as actor type. Forward MULTICA_TASK_ID as X-Task-ID header from CLI client. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(server): add debug logging and test coverage for resolveActor Add slog.Debug on agent/task validation failures for easier debugging. Add TestResolveActor with 5 cases covering member fallback, valid agent, non-existent agent, valid task, and mismatched task. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
810f2df8be
commit
d41b986cb0
7 changed files with 159 additions and 30 deletions
|
|
@ -44,6 +44,9 @@ func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) {
|
|||
if agentID := os.Getenv("MULTICA_AGENT_ID"); agentID != "" {
|
||||
client.AgentID = agentID
|
||||
}
|
||||
if taskID := os.Getenv("MULTICA_TASK_ID"); taskID != "" {
|
||||
client.TaskID = taskID
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: allowedOrigins(),
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID", "X-Agent-ID", "X-Task-ID"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type APIClient struct {
|
|||
WorkspaceID string
|
||||
Token string
|
||||
AgentID string // When set, requests are attributed to this agent instead of the user.
|
||||
TaskID string // When set, sent as X-Task-ID for agent-task validation.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +46,9 @@ func (c *APIClient) setHeaders(req *http.Request) {
|
|||
if c.AgentID != "" {
|
||||
req.Header.Set("X-Agent-ID", c.AgentID)
|
||||
}
|
||||
if c.TaskID != "" {
|
||||
req.Header.Set("X-Task-ID", c.TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
// GetJSON performs a GET request and decodes the JSON response.
|
||||
|
|
|
|||
|
|
@ -102,16 +102,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Determine author identity: agent (via X-Agent-ID header) or member.
|
||||
authorType := "member"
|
||||
authorID := userID
|
||||
if agentID := r.Header.Get("X-Agent-ID"); agentID != "" {
|
||||
// Validate the agent exists in this workspace.
|
||||
agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID))
|
||||
if err == nil && uuidToString(agent.WorkspaceID) == uuidToString(issue.WorkspaceID) {
|
||||
authorType = "agent"
|
||||
authorID = agentID
|
||||
}
|
||||
}
|
||||
authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
|
||||
|
||||
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
|
||||
IssueID: issue.ID,
|
||||
|
|
@ -205,8 +196,9 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
resp := commentToResponse(comment)
|
||||
actorType, actorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
|
||||
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
|
||||
h.publish(protocol.EventCommentUpdated, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{"comment": resp})
|
||||
h.publish(protocol.EventCommentUpdated, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"comment": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
|
|
@ -250,8 +242,9 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
actorType, actorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
|
||||
slog.Info("comment deleted", append(logger.RequestAttrs(r), "comment_id", commentId, "issue_id", uuidToString(comment.IssueID))...)
|
||||
h.publish(protocol.EventCommentDeleted, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{
|
||||
h.publish(protocol.EventCommentDeleted, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{
|
||||
"comment_id": commentId,
|
||||
"issue_id": uuidToString(comment.IssueID),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
|
@ -99,6 +100,36 @@ func requestUserID(r *http.Request) string {
|
|||
return r.Header.Get("X-User-ID")
|
||||
}
|
||||
|
||||
// resolveActor determines whether the request is from an agent or a human member.
|
||||
// If X-Agent-ID and X-Task-ID headers are both set, validates that the task
|
||||
// belongs to the claimed agent (defense-in-depth against manual header spoofing).
|
||||
// If only X-Agent-ID is set, validates that the agent belongs to the workspace.
|
||||
// Returns ("agent", agentID) on success, ("member", userID) otherwise.
|
||||
func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (actorType, actorID string) {
|
||||
agentID := r.Header.Get("X-Agent-ID")
|
||||
if agentID == "" {
|
||||
return "member", userID
|
||||
}
|
||||
|
||||
// Validate the agent exists in the target workspace.
|
||||
agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID))
|
||||
if err != nil || uuidToString(agent.WorkspaceID) != workspaceID {
|
||||
slog.Debug("resolveActor: X-Agent-ID rejected, agent not found or workspace mismatch", "agent_id", agentID, "workspace_id", workspaceID)
|
||||
return "member", userID
|
||||
}
|
||||
|
||||
// When X-Task-ID is provided, cross-check that the task belongs to this agent.
|
||||
if taskID := r.Header.Get("X-Task-ID"); taskID != "" {
|
||||
task, err := h.Queries.GetAgentTask(r.Context(), parseUUID(taskID))
|
||||
if err != nil || uuidToString(task.AgentID) != agentID {
|
||||
slog.Debug("resolveActor: X-Task-ID rejected, task not found or agent mismatch", "agent_id", agentID, "task_id", taskID)
|
||||
return "member", userID
|
||||
}
|
||||
}
|
||||
|
||||
return "agent", agentID
|
||||
}
|
||||
|
||||
func requireUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
userID := requestUserID(r)
|
||||
if userID == "" {
|
||||
|
|
|
|||
|
|
@ -609,6 +609,117 @@ func TestVerifyCodeCreatesWorkspace(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveActor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Look up the agent created by the test fixture.
|
||||
var agentID string
|
||||
err := testPool.QueryRow(ctx,
|
||||
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, "Handler Test Agent",
|
||||
).Scan(&agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find test agent: %v", err)
|
||||
}
|
||||
|
||||
// Create a task for the agent so we can test X-Task-ID validation.
|
||||
var issueID string
|
||||
err = testPool.QueryRow(ctx,
|
||||
`INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number, position)
|
||||
VALUES ($1, 'resolveActor test', 'todo', 'none', 'member', $2, 9999, 0)
|
||||
RETURNING id`, testWorkspaceID, testUserID,
|
||||
).Scan(&issueID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test issue: %v", err)
|
||||
}
|
||||
|
||||
// Look up runtime_id for the agent.
|
||||
var runtimeID string
|
||||
err = testPool.QueryRow(ctx, `SELECT runtime_id FROM agent WHERE id = $1`, agentID).Scan(&runtimeID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get agent runtime_id: %v", err)
|
||||
}
|
||||
|
||||
var taskID string
|
||||
err = testPool.QueryRow(ctx,
|
||||
`INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
|
||||
VALUES ($1, $2, $3, 'queued', 0)
|
||||
RETURNING id`, agentID, runtimeID, issueID,
|
||||
).Scan(&taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test task: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentIDHeader string
|
||||
taskIDHeader string
|
||||
wantActorType string
|
||||
wantIsAgent bool
|
||||
}{
|
||||
{
|
||||
name: "no headers returns member",
|
||||
wantActorType: "member",
|
||||
},
|
||||
{
|
||||
name: "valid agent ID returns agent",
|
||||
agentIDHeader: agentID,
|
||||
wantActorType: "agent",
|
||||
wantIsAgent: true,
|
||||
},
|
||||
{
|
||||
name: "non-existent agent ID returns member",
|
||||
agentIDHeader: "00000000-0000-0000-0000-000000000099",
|
||||
wantActorType: "member",
|
||||
},
|
||||
{
|
||||
name: "valid agent + valid task returns agent",
|
||||
agentIDHeader: agentID,
|
||||
taskIDHeader: taskID,
|
||||
wantActorType: "agent",
|
||||
wantIsAgent: true,
|
||||
},
|
||||
{
|
||||
name: "valid agent + wrong task returns member",
|
||||
agentIDHeader: agentID,
|
||||
taskIDHeader: "00000000-0000-0000-0000-000000000099",
|
||||
wantActorType: "member",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := newRequest("GET", "/test", nil)
|
||||
if tt.agentIDHeader != "" {
|
||||
req.Header.Set("X-Agent-ID", tt.agentIDHeader)
|
||||
}
|
||||
if tt.taskIDHeader != "" {
|
||||
req.Header.Set("X-Task-ID", tt.taskIDHeader)
|
||||
}
|
||||
|
||||
actorType, actorID := testHandler.resolveActor(req, testUserID, testWorkspaceID)
|
||||
|
||||
if actorType != tt.wantActorType {
|
||||
t.Errorf("actorType = %q, want %q", actorType, tt.wantActorType)
|
||||
}
|
||||
if tt.wantIsAgent {
|
||||
if actorID != tt.agentIDHeader {
|
||||
t.Errorf("actorID = %q, want agent %q", actorID, tt.agentIDHeader)
|
||||
}
|
||||
} else {
|
||||
if actorID != testUserID {
|
||||
t.Errorf("actorID = %q, want user %q", actorID, testUserID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/daemon/register", bytes.NewBufferString(`{
|
||||
|
|
|
|||
|
|
@ -221,14 +221,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Determine creator identity: agent (via X-Agent-ID header) or member.
|
||||
creatorType := "member"
|
||||
actualCreatorID := creatorID
|
||||
if agentID := r.Header.Get("X-Agent-ID"); agentID != "" {
|
||||
if agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID)); err == nil && uuidToString(agent.WorkspaceID) == workspaceID {
|
||||
creatorType = "agent"
|
||||
actualCreatorID = agentID
|
||||
}
|
||||
}
|
||||
creatorType, actualCreatorID := h.resolveActor(r, creatorID, workspaceID)
|
||||
|
||||
issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
|
|
@ -382,14 +375,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
(prevDueDate != nil && resp.DueDate != nil && *prevDueDate != *resp.DueDate)
|
||||
|
||||
// Determine actor identity: agent (via X-Agent-ID header) or member.
|
||||
actorType := "member"
|
||||
actorID := userID
|
||||
if agentID := r.Header.Get("X-Agent-ID"); agentID != "" {
|
||||
if agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID)); err == nil && uuidToString(agent.WorkspaceID) == workspaceID {
|
||||
actorType = "agent"
|
||||
actorID = agentID
|
||||
}
|
||||
}
|
||||
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
||||
|
||||
h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, map[string]any{
|
||||
"issue": resp,
|
||||
|
|
@ -495,7 +481,8 @@ func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
userID := requestUserID(r)
|
||||
h.publish(protocol.EventIssueDeleted, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{"issue_id": id})
|
||||
actorType, actorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
|
||||
h.publish(protocol.EventIssueDeleted, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"issue_id": id})
|
||||
slog.Info("issue deleted", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", uuidToString(issue.WorkspaceID))...)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue