molecule-core/platform/internal/handlers/activity.go
Hongming Wang 35705274c9 fix(code-review): CanvasOrBearer fall-through, scheduler short(), activity spoof log + 6 new tests
Addresses self-review of the 10-PR batch merged earlier this session.
Splits the follow-ups into this Go-side PR and a later Python/docs PR.

## Fixes

1. wsauth_middleware.go CanvasOrBearer — invalid bearer now hard-rejects
   with 401 instead of falling through to the Origin check. Previous code
   let an attacker with an expired token + matching Origin bypass auth.
   Empty bearer still falls through to the Origin path (the intended
   canvas path).

2. scheduler.go short() helper — extracts safe UUID prefix truncation.
   Pre-existing unsafe [:12] and [:8] slices would panic on workspace IDs
   shorter than the bound. #115's new skip path had the bounds check;
   the happy-path log lines did not. One helper, three call sites.

3. activity.go security-event log on source_id spoof — #209 added the
   403 but the attempt was invisible to any auditor cron. Stable
   greppable log line with authed_workspace, body_source_id, client IP.

## New tests

- TestShort_helper — bounds-safety regression guard for the helper
- TestRecordSkipped_writesSkippedStatus — #115 coverage gap, exercises
  UPDATE + INSERT via sqlmock
- TestRecordSkipped_shortWorkspaceIDNoPanic — short-ID crash regression
- TestActivityHandler_Report_SourceIDSpoofRejected — #209 403 path
- TestActivityHandler_Report_MatchingSourceIDAccepted — non-spoof path
- TestHistory_IncludesErrorDetail — #152 problem B coverage

go test -race ./... green locally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:48:25 -07:00

431 lines
13 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/gin-gonic/gin"
)
type ActivityHandler struct {
broadcaster *events.Broadcaster
}
func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
return &ActivityHandler{broadcaster: b}
}
// List handles GET /workspaces/:id/activity?type=&limit=
func (h *ActivityHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
activityType := c.Query("type")
source := c.Query("source") // "canvas" = source_id IS NULL, "agent" = source_id IS NOT NULL
limitStr := c.DefaultQuery("limit", "100")
limit := 100
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
limit = n
if limit > 500 {
limit = 500
}
}
// Build query with optional filters
query := `SELECT id, workspace_id, activity_type, source_id, target_id, method,
summary, request_body, response_body, duration_ms, status, error_detail, created_at
FROM activity_logs WHERE workspace_id = $1`
args := []interface{}{workspaceID}
argIdx := 2
if activityType != "" {
query += fmt.Sprintf(" AND activity_type = $%d", argIdx)
args = append(args, activityType)
argIdx++
}
if source == "canvas" {
query += " AND source_id IS NULL"
} else if source == "agent" {
query += " AND source_id IS NOT NULL"
} else if source != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "source must be 'canvas' or 'agent'"})
return
}
query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d", argIdx)
args = append(args, limit)
rows, err := db.DB.QueryContext(c.Request.Context(), query, args...)
if err != nil {
log.Printf("Activity list error for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
defer rows.Close()
activities := make([]map[string]interface{}, 0)
for rows.Next() {
var id, wsID, actType, status string
var sourceID, targetID, method, summary, errorDetail *string
var reqBody, respBody []byte
var durationMs *int
var createdAt time.Time
if err := rows.Scan(&id, &wsID, &actType, &sourceID, &targetID, &method,
&summary, &reqBody, &respBody, &durationMs, &status, &errorDetail, &createdAt); err != nil {
log.Printf("Activity scan error: %v", err)
continue
}
entry := map[string]interface{}{
"id": id,
"workspace_id": wsID,
"activity_type": actType,
"source_id": sourceID,
"target_id": targetID,
"method": method,
"summary": summary,
"duration_ms": durationMs,
"status": status,
"error_detail": errorDetail,
"created_at": createdAt,
}
if reqBody != nil {
entry["request_body"] = json.RawMessage(reqBody)
}
if respBody != nil {
entry["response_body"] = json.RawMessage(respBody)
}
activities = append(activities, entry)
}
if err := rows.Err(); err != nil {
log.Printf("Activity list rows error for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query iteration failed"})
return
}
c.JSON(http.StatusOK, activities)
}
// SessionSearch handles GET /workspaces/:id/session-search?q=&limit=
// It searches the workspace's own activity logs and memories without adding a new storage layer.
func (h *ActivityHandler) SessionSearch(c *gin.Context) {
workspaceID := c.Param("id")
query, limit := parseSessionSearchParams(c)
sqlQuery, args := buildSessionSearchQuery(workspaceID, query, limit)
rows, err := db.DB.QueryContext(c.Request.Context(), sqlQuery, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session search failed"})
return
}
defer rows.Close()
items, scanErr := scanSessionSearchRows(rows)
if scanErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query iteration failed"})
return
}
c.JSON(http.StatusOK, items)
}
// parseSessionSearchParams extracts the `q` and `limit` query params for SessionSearch,
// applying the default limit (50) and cap (200).
func parseSessionSearchParams(c *gin.Context) (string, int) {
query := strings.TrimSpace(c.DefaultQuery("q", ""))
limit := 50
if n, err := strconv.Atoi(c.DefaultQuery("limit", "50")); err == nil && n > 0 {
limit = n
if limit > 200 {
limit = 200
}
}
return query, limit
}
// buildSessionSearchQuery composes the UNION-ALL SQL across activity_logs and
// agent_memories with an optional ILIKE filter, returning the SQL string and
// positional args ready for QueryContext.
func buildSessionSearchQuery(workspaceID, query string, limit int) (string, []interface{}) {
sqlQuery := `
WITH session_items AS (
SELECT
'activity' AS kind,
id,
workspace_id,
activity_type AS label,
COALESCE(summary, '') AS content,
COALESCE(method, '') AS method,
COALESCE(status, '') AS status,
request_body,
response_body,
created_at
FROM activity_logs
WHERE workspace_id = $1
UNION ALL
SELECT
'memory' AS kind,
id,
workspace_id,
scope AS label,
content,
'' AS method,
'' AS status,
NULL::jsonb AS request_body,
NULL::jsonb AS response_body,
created_at
FROM agent_memories
WHERE workspace_id = $1
)
SELECT kind, id, workspace_id, label, content, method, status, request_body, response_body, created_at
FROM session_items
`
args := []interface{}{workspaceID}
if query != "" {
sqlQuery += `
WHERE (
content ILIKE $2 OR
label ILIKE $2 OR
method ILIKE $2 OR
status ILIKE $2 OR
COALESCE(request_body::text, '') ILIKE $2 OR
COALESCE(response_body::text, '') ILIKE $2
)`
args = append(args, "%"+query+"%")
}
sqlQuery += ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(len(args)+1)
args = append(args, limit)
return sqlQuery, args
}
// scanSessionSearchRows materialises rows from the SessionSearch query into the
// JSON-shaped maps the endpoint returns. Per-row scan errors are logged and
// skipped (matches prior behavior); a rows.Err() failure is surfaced.
func scanSessionSearchRows(rows interface {
Next() bool
Scan(dest ...interface{}) error
Err() error
}) ([]map[string]interface{}, error) {
items := make([]map[string]interface{}, 0)
for rows.Next() {
var (
kind, id, wsID, label, content, method, status string
reqBody, respBody []byte
createdAt time.Time
)
if err := rows.Scan(&kind, &id, &wsID, &label, &content, &method, &status, &reqBody, &respBody, &createdAt); err != nil {
log.Printf("Session search scan error: %v", err)
continue
}
item := map[string]interface{}{
"kind": kind,
"id": id,
"workspace_id": wsID,
"label": label,
"content": content,
"method": method,
"status": status,
"created_at": createdAt,
}
if reqBody != nil {
item["request_body"] = json.RawMessage(reqBody)
}
if respBody != nil {
item["response_body"] = json.RawMessage(respBody)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
log.Printf("Session search rows error: %v", err)
return nil, err
}
return items, nil
}
// Notify handles POST /workspaces/:id/notify — agents push messages to the canvas chat.
// This enables agents to send interim updates ("I'll check on it") and follow-up results
// without waiting for the user to poll. Messages are broadcast via WebSocket only.
func (h *ActivityHandler) Notify(c *gin.Context) {
workspaceID := c.Param("id")
var body struct {
Message string `json:"message" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "message is required"})
return
}
// Verify workspace exists
var wsName string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT name FROM workspaces WHERE id = $1 AND status != 'removed'`, workspaceID,
).Scan(&wsName)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
h.broadcaster.BroadcastOnly(workspaceID, "AGENT_MESSAGE", map[string]interface{}{
"message": body.Message,
"workspace_id": workspaceID,
"name": wsName,
})
c.JSON(http.StatusOK, gin.H{"status": "sent"})
}
// Report handles POST /workspaces/:id/activity — agents self-report activity logs.
func (h *ActivityHandler) Report(c *gin.Context) {
workspaceID := c.Param("id")
var body struct {
ActivityType string `json:"activity_type" binding:"required"`
Method string `json:"method"`
Summary string `json:"summary"`
TargetID string `json:"target_id"`
SourceID string `json:"source_id"`
Status string `json:"status"`
ErrorDetail string `json:"error_detail"`
DurationMs *int `json:"duration_ms"`
RequestBody interface{} `json:"request_body"`
ResponseBody interface{} `json:"response_body"`
Metadata interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate activity type. memory_write was added per #125 so the
// commit_memory tool can surface in the Canvas Agent Comms tab —
// previously its writes were invisible outside the agent_memories
// table.
switch body.ActivityType {
case "a2a_send", "a2a_receive", "task_update", "agent_log", "skill_promotion", "memory_write", "error":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid activity_type, must be one of: a2a_send, a2a_receive, task_update, agent_log, skill_promotion, memory_write, error"})
return
}
status := body.Status
if status == "" {
status = "ok"
}
// Resolve request/response body — prefer explicit fields, fall back to metadata
reqBody := body.RequestBody
if reqBody == nil {
reqBody = body.Metadata
}
// C2 (from #169) — source_id spoof defense. WorkspaceAuth middleware
// already proves the caller owns :id, but that check doesn't cover the
// body field. Without this guard, workspace A authenticated for its own
// /activity endpoint could still set source_id=<workspace B's UUID> in
// the payload and attribute the log to B. Reject any body where
// source_id is non-empty AND differs from the authenticated workspace.
// Empty source_id falls through to the default-to-self branch below.
sourceID := body.SourceID
if sourceID != "" && sourceID != workspaceID {
// Log the spoof attempt as a security event so an auditor cron can
// surface repeat probing. Keep the log line stable (greppable) and
// avoid echoing attacker-supplied data verbatim beyond the UUIDs.
log.Printf("security: source_id spoof attempt — authed_workspace=%s body_source_id=%s remote=%s",
workspaceID, sourceID, c.ClientIP())
c.JSON(http.StatusForbidden, gin.H{"error": "source_id must match authenticated workspace"})
return
}
if sourceID == "" {
sourceID = workspaceID
}
LogActivity(c.Request.Context(), h.broadcaster, ActivityParams{
WorkspaceID: workspaceID,
ActivityType: body.ActivityType,
SourceID: &sourceID,
TargetID: nilIfEmpty(body.TargetID),
Method: nilIfEmpty(body.Method),
Summary: nilIfEmpty(body.Summary),
RequestBody: reqBody,
ResponseBody: body.ResponseBody,
DurationMs: body.DurationMs,
Status: status,
ErrorDetail: nilIfEmpty(body.ErrorDetail),
})
c.JSON(http.StatusOK, gin.H{"status": "logged"})
}
// LogActivity inserts an activity log and optionally broadcasts via WebSocket.
func LogActivity(ctx context.Context, broadcaster *events.Broadcaster, params ActivityParams) {
reqJSON, reqErr := json.Marshal(params.RequestBody)
if reqErr != nil {
log.Printf("LogActivity: failed to marshal request_body for %s: %v", params.WorkspaceID, reqErr)
reqJSON = []byte("null")
}
respJSON, respErr := json.Marshal(params.ResponseBody)
if respErr != nil {
log.Printf("LogActivity: failed to marshal response_body for %s: %v", params.WorkspaceID, respErr)
respJSON = []byte("null")
}
var reqStr, respStr *string
if params.RequestBody != nil {
s := string(reqJSON)
reqStr = &s
}
if params.ResponseBody != nil {
s := string(respJSON)
respStr = &s
}
_, err := db.DB.ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, source_id, target_id, method, summary, request_body, response_body, duration_ms, status, error_detail)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10, $11)
`, params.WorkspaceID, params.ActivityType, params.SourceID, params.TargetID,
params.Method, params.Summary, reqStr, respStr,
params.DurationMs, params.Status, params.ErrorDetail)
if err != nil {
log.Printf("LogActivity insert error: %v", err)
return
}
// Broadcast ACTIVITY_LOGGED event
if broadcaster != nil {
broadcaster.BroadcastOnly(params.WorkspaceID, "ACTIVITY_LOGGED", map[string]interface{}{
"activity_type": params.ActivityType,
"method": params.Method,
"summary": params.Summary,
"status": params.Status,
"source_id": params.SourceID,
"target_id": params.TargetID,
"duration_ms": params.DurationMs,
})
}
}
type ActivityParams struct {
WorkspaceID string
ActivityType string // a2a_send, a2a_receive, task_update, agent_log, skill_promotion, error
SourceID *string
TargetID *string
Method *string
Summary *string
RequestBody interface{}
ResponseBody interface{}
DurationMs *int
Status string // ok, error, timeout
ErrorDetail *string
}