diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index a5e3c808cae..3327b0ea390 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -1077,6 +1077,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus // --- User-scoped routes (no workspace context required) --- r.Get("/api/me", h.GetMe) + r.Get("/api/task-context", h.GetTaskContext) r.Patch("/api/me", h.UpdateMe) r.Patch("/api/me/onboarding", h.PatchOnboarding) r.Post("/api/me/onboarding/complete", h.CompleteOnboarding) diff --git a/server/internal/handler/task_context.go b/server/internal/handler/task_context.go new file mode 100644 index 00000000000..e262bcb43dc --- /dev/null +++ b/server/internal/handler/task_context.go @@ -0,0 +1,36 @@ +package handler + +import "net/http" + +// TaskContextResponse is an attestation derived exclusively from the current +// mat_ task token. The auth middleware overwrites these headers from the token +// row, so callers cannot substitute another task, agent, or workspace. +type TaskContextResponse struct { + UserID string `json:"user_id"` + AgentID string `json:"agent_id"` + TaskID string `json:"task_id"` + WorkspaceID string `json:"workspace_id"` +} + +// GetTaskContext exposes the server-bound identity of the current task token. +// Human JWTs and personal access tokens are rejected even if they forge the +// context headers because only Auth may stamp X-Actor-Source=task_token. +func (h *Handler) GetTaskContext(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Actor-Source") != "task_token" { + writeError(w, http.StatusForbidden, "task context requires a task token") + return + } + + context := TaskContextResponse{ + UserID: r.Header.Get("X-User-ID"), + AgentID: r.Header.Get("X-Agent-ID"), + TaskID: r.Header.Get("X-Task-ID"), + WorkspaceID: r.Header.Get("X-Workspace-ID"), + } + if context.UserID == "" || context.AgentID == "" || context.TaskID == "" || context.WorkspaceID == "" { + writeError(w, http.StatusUnauthorized, "task token context is incomplete") + return + } + + writeJSON(w, http.StatusOK, context) +} diff --git a/server/internal/handler/task_context_test.go b/server/internal/handler/task_context_test.go new file mode 100644 index 00000000000..fe80905e3b4 --- /dev/null +++ b/server/internal/handler/task_context_test.go @@ -0,0 +1,65 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetTaskContextReturnsServerStampedBinding(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/task-context", nil) + req.Header.Set("X-Actor-Source", "task_token") + req.Header.Set("X-User-ID", "user-1") + req.Header.Set("X-Agent-ID", "agent-1") + req.Header.Set("X-Task-ID", "task-1") + req.Header.Set("X-Workspace-ID", "workspace-1") + w := httptest.NewRecorder() + + (&Handler{}).GetTaskContext(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String()) + } + var got TaskContextResponse + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + want := (TaskContextResponse{ + UserID: "user-1", + AgentID: "agent-1", + TaskID: "task-1", + WorkspaceID: "workspace-1", + }) + if got != want { + t.Fatalf("response = %+v, want %+v", got, want) + } +} + +func TestGetTaskContextRejectsHumanCredentialShape(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/task-context", nil) + // A human PAT can submit these headers, but Auth does not stamp the source. + req.Header.Set("X-Agent-ID", "forged-agent") + req.Header.Set("X-Task-ID", "forged-task") + req.Header.Set("X-Workspace-ID", "forged-workspace") + w := httptest.NewRecorder() + + (&Handler{}).GetTaskContext(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403: %s", w.Code, w.Body.String()) + } +} + +func TestGetTaskContextRejectsIncompleteStampedContext(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/task-context", nil) + req.Header.Set("X-Actor-Source", "task_token") + req.Header.Set("X-User-ID", "user-1") + w := httptest.NewRecorder() + + (&Handler{}).GetTaskContext(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401: %s", w.Code, w.Body.String()) + } +}