agent build
This commit is contained in:
@@ -222,11 +222,16 @@ func TestListEveryResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Assignments are empty by design in the source dataset. An empty collection is
|
||||
// 200 with an empty array, never a 404. api-contract.md §8.
|
||||
// An empty result is 200 with an empty array, never a 404. api-contract.md §8.
|
||||
//
|
||||
// Asked as a filter that matches nothing, rather than as a collection that
|
||||
// happens to be empty. This used to read /assignments on the strength of the
|
||||
// fixture shipping none, so seeding a single assignment broke a test about
|
||||
// status codes. The contract is about the empty result, not about which
|
||||
// collection is empty this week.
|
||||
func TestEmptyCollectionIs200(t *testing.T) {
|
||||
a := newAPI(t)
|
||||
r := a.do("GET", "/api/v1/assignments", nil)
|
||||
r := a.do("GET", "/api/v1/assignments?status=cancelled", nil)
|
||||
if r.code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200", r.code)
|
||||
}
|
||||
@@ -473,12 +478,16 @@ func TestFilterEquality(t *testing.T) {
|
||||
// `Array.isArray(want) ? want.includes(got)`. api-contract.md §6.
|
||||
func TestFilterArrayMeansIN(t *testing.T) {
|
||||
a := newAPI(t)
|
||||
recs := a.do("GET", "/api/v1/job-applications?status=hired&status=interview", nil).records(t)
|
||||
// `assigned` is asked for deliberately: the fixture now carries one, and this
|
||||
// filter is literal — it matches the stored value, not the product's rule
|
||||
// that an assigned candidate also counts as hired.
|
||||
recs := a.do("GET",
|
||||
"/api/v1/job-applications?status=hired&status=interview&status=assigned", nil).records(t)
|
||||
if len(recs) != 8 {
|
||||
t.Errorf("hired+interview = %d, want 8 (3 hired, 5 interview)", len(recs))
|
||||
t.Errorf("hired+interview+assigned = %d, want 8 (2 hired, 5 interview, 1 assigned)", len(recs))
|
||||
}
|
||||
for _, r := range recs {
|
||||
if s := r["status"].(string); s != "hired" && s != "interview" {
|
||||
if s := r["status"].(string); s != "hired" && s != "interview" && s != "assigned" {
|
||||
t.Errorf("membership filter leaked status %q", s)
|
||||
}
|
||||
}
|
||||
@@ -1112,3 +1121,90 @@ func keysOf(m map[string]any) []string {
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// The build identifier has to be reachable, or "did my deploy land?" has no
|
||||
// answer. It was reported nowhere: the Dockerfile declared a VERSION arg,
|
||||
// compose passed it, and it reached no linker flag — so every deployment
|
||||
// described itself as nothing at all.
|
||||
//
|
||||
// Under /api/v1 rather than on /health on purpose: /health is public and
|
||||
// deliberately withholds its detail from the internet, and a build identifier
|
||||
// tells an unauthenticated reader exactly which source to go and read.
|
||||
func TestVersionEndpointReportsTheBuild(t *testing.T) {
|
||||
a := newAPI(t)
|
||||
|
||||
r := a.do("GET", "/api/v1/version", nil)
|
||||
if r.code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200", r.code)
|
||||
}
|
||||
data, _ := r.body["data"].(map[string]any)
|
||||
if data == nil {
|
||||
t.Fatalf("no data envelope: %v", r.body)
|
||||
}
|
||||
if v, _ := data["version"].(string); v == "" {
|
||||
t.Errorf("version is empty; an unstamped build should still say \"dev\": %v", data)
|
||||
}
|
||||
if e, _ := data["env"].(string); e == "" {
|
||||
t.Errorf("env is empty: %v", data)
|
||||
}
|
||||
if n, _ := data["endpoints"].(float64); n < 1 {
|
||||
t.Errorf("endpoints = %v, want the served route count", data["endpoints"])
|
||||
}
|
||||
}
|
||||
|
||||
// It is behind the session like every other /api/v1 route.
|
||||
func TestVersionEndpointNeedsASession(t *testing.T) {
|
||||
a := newAPI(t)
|
||||
r := a.doAnon("GET", "/api/v1/version", nil)
|
||||
if r.code != http.StatusUnauthorized && r.code != http.StatusForbidden {
|
||||
t.Errorf("anonymous GET /api/v1/version = %d, want 401/403", r.code)
|
||||
}
|
||||
}
|
||||
|
||||
// An agent author picks capabilities from the real tool set, not a copy of it
|
||||
// kept in the frontend. A second list would drift, and the failure is silent:
|
||||
// the author picks a tool that no longer exists and gets an agent that quietly
|
||||
// cannot do the thing they picked.
|
||||
func TestToolsCatalogueIsServed(t *testing.T) {
|
||||
a := newAPI(t)
|
||||
|
||||
r := a.do("GET", "/api/v1/tools", nil)
|
||||
if r.code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200", r.code)
|
||||
}
|
||||
list, _ := r.body["data"].([]any)
|
||||
if len(list) == 0 {
|
||||
t.Fatalf("no tools served: %v", r.body)
|
||||
}
|
||||
|
||||
seenWrite := false
|
||||
for _, raw := range list {
|
||||
tool, _ := raw.(map[string]any)
|
||||
name, _ := tool["name"].(string)
|
||||
desc, _ := tool["description"].(string)
|
||||
effect, _ := tool["effect"].(string)
|
||||
if name == "" || desc == "" {
|
||||
t.Errorf("a tool has no name or description: %v", tool)
|
||||
}
|
||||
if effect != "read" && effect != "write" {
|
||||
t.Errorf("%s has effect %q, want read or write", name, effect)
|
||||
}
|
||||
if effect == "write" {
|
||||
seenWrite = true
|
||||
// An author must be able to see that this one proposes changes.
|
||||
if confirm, _ := tool["requiresConfirmation"].(bool); !confirm {
|
||||
t.Errorf("%s writes but does not report requiring confirmation", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seenWrite {
|
||||
t.Error("no write tool in the catalogue; the effect distinction is untested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsCatalogueNeedsASession(t *testing.T) {
|
||||
a := newAPI(t)
|
||||
if r := a.doAnon("GET", "/api/v1/tools", nil); r.code != http.StatusUnauthorized && r.code != http.StatusForbidden {
|
||||
t.Errorf("anonymous GET /api/v1/tools = %d, want 401/403", r.code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,34 @@ func (s *Server) secureCookies() bool { return s.cfg.AppEnv != "development" }
|
||||
// only mode a browser will send cross-site, and it requires Secure — which is
|
||||
// why an origin allowlist forces Secure on regardless of AppEnv.
|
||||
func (s *Server) sessionSameSite() http.SameSite {
|
||||
// An explicit HTTP_COOKIE_SAMESITE wins, because the derivation below
|
||||
// cannot see the one thing that decides the answer: whether the frontend
|
||||
// is on the same SITE as this API.
|
||||
//
|
||||
// CORS is about ORIGIN and SameSite is about SITE, and they are not the
|
||||
// same question. platform.krowforce.com calling mcp.krowforce.com is
|
||||
// cross-origin — so it needs the CORS allowlist — and same-site, so a Lax
|
||||
// cookie is sent on its requests anyway. Deriving None from "CORS is
|
||||
// configured" gives up the only CSRF protection this API has, in exchange
|
||||
// for nothing that deployment needed.
|
||||
//
|
||||
// So the allowlist decides the DEFAULT and an operator decides the value.
|
||||
// This also closes a trap: config.Load has always parsed and validated
|
||||
// HTTP_COOKIE_SAMESITE, and nothing read it — a deployment that set it saw
|
||||
// it silently ignored.
|
||||
switch s.cfg.HTTP.CookieSameSite {
|
||||
case "none":
|
||||
return http.SameSiteNoneMode
|
||||
case "strict":
|
||||
return http.SameSiteStrictMode
|
||||
case "lax":
|
||||
return http.SameSiteLaxMode
|
||||
}
|
||||
|
||||
// Unset. A configured CORS allowlist means a browser on another origin is
|
||||
// expected, and None is the only mode that survives a genuinely cross-site
|
||||
// one. Safe as a default because it is only reached when nobody has said
|
||||
// otherwise.
|
||||
if len(s.cfg.HTTP.CORSOrigins) > 0 {
|
||||
return http.SameSiteNoneMode
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package httpserver_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -846,3 +848,38 @@ pages:
|
||||
t.Errorf("get after delete: got %d, want 404", getAfterDel.code)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tool names are checked at publish ────────────────────────────────────── */
|
||||
|
||||
// §3: an unknown tool name fails validation at PUBLISH. Before this, the name
|
||||
// was accepted, stored, and dropped by the runtime at resolve time — so an
|
||||
// author got an agent that was silently missing a capability they believed they
|
||||
// had chosen, and found out by watching it fail to answer.
|
||||
func TestAgentCreateRejectsAnUnknownToolName(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
|
||||
withTools := func(names string) string {
|
||||
return strings.Replace(validAgentMD, "pages:\n - candidates",
|
||||
"tools:\n"+names+"pages:\n - candidates", 1)
|
||||
}
|
||||
|
||||
res := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
||||
"markdown": withTools(" - not_a_real_tool\n"),
|
||||
"visibility": "personal",
|
||||
})
|
||||
if res.code != http.StatusBadRequest && res.code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("unknown tool accepted: status %d (%v)", res.code, res.body)
|
||||
}
|
||||
if body, _ := json.Marshal(res.body); !strings.Contains(string(body), "not_a_real_tool") {
|
||||
t.Errorf("the error does not name the offending tool: %s", body)
|
||||
}
|
||||
|
||||
// A real tool is accepted, so the check is not simply refusing everything.
|
||||
ok := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{
|
||||
"markdown": withTools(" - candidates_awaiting\n"),
|
||||
"visibility": "personal",
|
||||
})
|
||||
if ok.code != http.StatusCreated {
|
||||
t.Fatalf("a real tool was refused: status %d (%v)", ok.code, ok.body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,6 @@ func (s *Server) handleOwliverSuggestions(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, envelope{
|
||||
Data: suggestionsBody{Suggestions: s.suggestions.Suggest(ident, params)},
|
||||
Data: suggestionsBody{Suggestions: s.suggestions.Suggest(r.Context(), ident, params)},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -188,11 +188,17 @@ func TestOwliverSuggestionsCarryARequestedShape(t *testing.T) {
|
||||
}
|
||||
|
||||
// No match is an empty array, not an error and not null.
|
||||
//
|
||||
// "Nothing typed" is deliberately absent from this list. It used to be here,
|
||||
// and it stopped being a case of "no match" when the endpoint gained an
|
||||
// organization context: with nothing typed there is now something to rank —
|
||||
// the state of the data — and TestOwliverHighlightsComeFromTheDatabase covers
|
||||
// it. A query that WAS typed and matches nothing still answers with nothing,
|
||||
// which is the case this test exists for.
|
||||
func TestOwliverSuggestionsEmptyResults(t *testing.T) {
|
||||
a := newAPI(t)
|
||||
|
||||
for _, c := range []struct{ name, query string }{
|
||||
{"nothing typed", ""},
|
||||
{"one character", "p"},
|
||||
{"irrelevant", "sourdough starter recipe"},
|
||||
} {
|
||||
@@ -203,9 +209,117 @@ func TestOwliverSuggestionsEmptyResults(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// A real surface the catalogue holds no readings for is the same answer.
|
||||
if got := suggestions(t, a.do("GET", suggestURL("settings", "owliver"), nil)); len(got) != 0 {
|
||||
t.Fatalf("settings returned %v", got)
|
||||
// A real surface the catalogue holds no readings for is the same answer,
|
||||
// typed against or not.
|
||||
for _, query := range []string{"owliver", ""} {
|
||||
if got := suggestions(t, a.do("GET", suggestURL("settings", query), nil)); len(got) != 0 {
|
||||
t.Fatalf("settings returned %v for query %q", got, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Context ────────────────────────────────────────────────────────────── */
|
||||
|
||||
// With nothing typed, the suggestions come from what is in PostgreSQL.
|
||||
//
|
||||
// This is the half of the endpoint that a static catalogue cannot serve: the
|
||||
// panel opens having been told nothing, and what it should offer depends on
|
||||
// whether this organization has unfinished drafts, unscored candidates or
|
||||
// positions nobody has applied to. The assertion is not on WHICH readings come
|
||||
// back — that is the catalogue's business and would pin this test to a ranking
|
||||
// weight — but that they are real readings, capped, and that the endpoint
|
||||
// reaches the database at all.
|
||||
func TestOwliverHighlightsComeFromTheDatabase(t *testing.T) {
|
||||
a := newAPI(t) // the harness seeds a populated organization
|
||||
|
||||
got := suggestions(t, a.do("GET", suggestURL("positions", ""), nil))
|
||||
if len(got) == 0 {
|
||||
t.Fatal("a seeded organization offered nothing with an empty query")
|
||||
}
|
||||
if len(got) > 3 {
|
||||
t.Fatalf("%d suggestions, the cap is 3", len(got))
|
||||
}
|
||||
for i, s := range got {
|
||||
text, _ := s["text"].(string)
|
||||
intent, _ := s["intent"].(string)
|
||||
if text == "" || intent == "" {
|
||||
t.Fatalf("suggestion %d is incomplete: %v", i, s)
|
||||
}
|
||||
// A highlight is not a shaped request: nothing was typed, so nothing
|
||||
// asked for a rendering.
|
||||
if _, present := s["capability"]; present {
|
||||
t.Fatalf("suggestion %d carries a shape nobody asked for: %v", i, s)
|
||||
}
|
||||
for key := range s {
|
||||
switch key {
|
||||
case "text", "intent":
|
||||
default:
|
||||
t.Fatalf("suggestion %d exposes %q: %v", i, key, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The ranking answers to the data, so changing the data changes the answer.
|
||||
//
|
||||
// This is the property the whole context read exists for, and the one the panel
|
||||
// depends on: a position created through the API must change what Owliver
|
||||
// offers afterwards. No seeded posting is a draft, so unfinished drafts are a
|
||||
// lever this test owns entirely — one filed here is the only one in the
|
||||
// organization, and the endpoint has to notice it.
|
||||
//
|
||||
// One is the point. A ranking that only reacts to a pile would be a ranking
|
||||
// that never reacts to the thing that just happened, which is exactly the stale
|
||||
// suggestion this replaced.
|
||||
func TestOwliverHighlightsReactToAMutation(t *testing.T) {
|
||||
a := newAPI(t)
|
||||
|
||||
names := func(list []map[string]any) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, s := range list {
|
||||
id, _ := s["intent"].(string)
|
||||
out[id] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
before := names(suggestions(t, a.do("GET", suggestURL("positions", ""), nil)))
|
||||
if before["position-drafts"] {
|
||||
t.Skip("the fixture already holds draft positions; this lever is not available")
|
||||
}
|
||||
|
||||
created := a.do("POST", "/api/v1/job-postings", map[string]any{
|
||||
"title": "Owliver Context Probe", "status": "draft",
|
||||
})
|
||||
if created.code != http.StatusCreated {
|
||||
t.Fatalf("creating the draft: status %d, body %v", created.code, created.body)
|
||||
}
|
||||
|
||||
after := names(suggestions(t, a.do("GET", suggestURL("positions", ""), nil)))
|
||||
if !after["position-drafts"] {
|
||||
t.Fatalf("filing a draft did not surface the drafts reading: %v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// A talent caller is offered no organization-wide count.
|
||||
//
|
||||
// The counts behind a highlight are org-wide by construction, and talent's rows
|
||||
// are narrowed by the policy table — so answering "eleven candidates are
|
||||
// waiting" to someone entitled to see one of them would leak the other ten
|
||||
// through an integer. Nothing on the operator pages may reach them.
|
||||
func TestOwliverHighlightsAreNotOfferedToTalent(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
|
||||
for _, page := range []string{"positions", "candidates", "control-center", "analytics"} {
|
||||
if got := suggestions(t, r.as(r.talA, "GET", suggestURL(page, ""), nil)); len(got) != 0 {
|
||||
t.Fatalf("%s offered talent %v", page, got)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator on the same pages is offered something, so the assertion
|
||||
// above is about the role rather than about the pages being empty.
|
||||
if got := suggestions(t, r.as(r.admin, "GET", suggestURL("positions", ""), nil)); len(got) == 0 {
|
||||
t.Fatal("an admin was offered nothing either — the fixture proves nothing")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
99
go-api/internal/httpserver/positions_contract_test.go
Normal file
99
go-api/internal/httpserver/positions_contract_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package httpserver_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The exact body the Owliver create-position skill sends, byte for byte as
|
||||
// `runAction('create_position', …)` produces it for the brief's own example.
|
||||
// Generated from the frontend, not retyped: if the two ever drift, this fails.
|
||||
const owliverCreatePositionBody = `{
|
||||
"title": "Event Staff",
|
||||
"role_category": "Event Staff",
|
||||
"company": "Mac",
|
||||
"headcount": 1,
|
||||
"start_date": null,
|
||||
"duration_months": null,
|
||||
"priority": "normal",
|
||||
"custom_requirements": "",
|
||||
"physical_requirements": "",
|
||||
"leadership_expectations": "",
|
||||
"attendance_expectations": "",
|
||||
"min_experience_years": 3,
|
||||
"english_required": "native",
|
||||
"location": "Bay Area",
|
||||
"pay_range_min": 30,
|
||||
"pay_range_max": 40,
|
||||
"certifications_required": ["Background Check Cleared"],
|
||||
"skill_requirements": [],
|
||||
"vetting_criteria": {"experience":25,"english":20,"reliability":20,"certifications":20,"availability":15},
|
||||
"status": "active"
|
||||
}`
|
||||
|
||||
func TestOwliverCreatePositionPayloadIsAccepted(t *testing.T) {
|
||||
a := newAPI(t)
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(owliverCreatePositionBody), &body); err != nil {
|
||||
t.Fatalf("the captured payload is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
got := a.do("POST", "/api/v1/job-postings", body)
|
||||
if got.code != http.StatusCreated {
|
||||
t.Fatalf("POST /api/v1/job-postings = %d, want 201\nbody: %v", got.code, got.body)
|
||||
}
|
||||
|
||||
rec, _ := got.body["data"].(map[string]any)
|
||||
if rec == nil {
|
||||
t.Fatalf("no record in the response: %v", got.body)
|
||||
}
|
||||
|
||||
// Every field the conversation collected must come back as it was sent —
|
||||
// a create that silently drops the pay range or the certification is a
|
||||
// create that looks fine and stores something else.
|
||||
for field, want := range map[string]any{
|
||||
"title": "Event Staff", "company": "Mac", "location": "Bay Area",
|
||||
"pay_range_min": float64(30), "pay_range_max": float64(40),
|
||||
"min_experience_years": float64(3), "english_required": "native",
|
||||
"status": "active",
|
||||
} {
|
||||
if rec[field] != want {
|
||||
t.Errorf("%s = %#v, want %#v", field, rec[field], want)
|
||||
}
|
||||
}
|
||||
certs, _ := rec["certifications_required"].([]any)
|
||||
if len(certs) != 1 || certs[0] != "Background Check Cleared" {
|
||||
t.Errorf("certifications_required = %#v", rec["certifications_required"])
|
||||
}
|
||||
id, _ := rec["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("the created position has no id")
|
||||
}
|
||||
|
||||
// And it is in PostgreSQL, not just in the response: read it back through
|
||||
// the list endpoint the Positions page uses.
|
||||
list := a.do("GET", "/api/v1/job-postings?limit=200", nil)
|
||||
if list.code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/job-postings = %d", list.code)
|
||||
}
|
||||
rows, _ := list.body["data"].([]any)
|
||||
for _, row := range rows {
|
||||
if r, ok := row.(map[string]any); ok && r["id"] == id {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("the created position is not in GET /api/v1/job-postings (%d rows)", len(rows))
|
||||
}
|
||||
|
||||
// The path the brief names does not exist, and never did. The resource is
|
||||
// job-postings; /api/v1/positions is a phantom.
|
||||
func TestThereIsNoPositionsResource(t *testing.T) {
|
||||
a := newAPI(t)
|
||||
for _, m := range []string{"GET", "POST"} {
|
||||
if got := a.do(m, "/api/v1/positions", map[string]any{"title": "x"}); got.code != http.StatusNotFound {
|
||||
t.Errorf("%s /api/v1/positions = %d, want 404", m, got.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
383
go-api/internal/httpserver/runs.go
Normal file
383
go-api/internal/httpserver/runs.go
Normal file
@@ -0,0 +1,383 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
"github.com/krow/krow-backend/go-api/internal/runtime"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// The agent run endpoint: the surface layer, and the first thing that can
|
||||
// actually call the runtime.
|
||||
//
|
||||
// Everything under internal/runtime, internal/tools and internal/knowledge has
|
||||
// been reachable only from tests until now. This file is the seam, and it has
|
||||
// two jobs that belong nowhere else:
|
||||
//
|
||||
// 1. **Deriving user-facing text.** §10 says user-facing wording is produced
|
||||
// at the surface, not raised from the core. The runtime returns a
|
||||
// Termination — an enum — and this file decides what a person reads for
|
||||
// each of the six. A run that hit its budget is not an internal error and
|
||||
// must not be answered as one.
|
||||
// 2. **Answering with a shape the client can act on.** A ConfirmationPending
|
||||
// run is not a failure: it is a question, it comes back 200 with the
|
||||
// confirmation payload, and the client's job is to ask a person and call
|
||||
// back with the token. Answering it 500 would make the whole write path
|
||||
// look broken.
|
||||
|
||||
func (s *Server) routeRuns(mux *http.ServeMux) int {
|
||||
if s.agents == nil {
|
||||
// No runtime wired — no model credential, or a deployment that does not
|
||||
// serve agents. The routes are not registered at all rather than
|
||||
// registered and always failing: a 404 says "this deployment does not
|
||||
// do that", where a 500 says "this deployment is broken", and only one
|
||||
// of those is true.
|
||||
return 0
|
||||
}
|
||||
mux.HandleFunc("POST /api/v1/agents/{id}/runs", s.handleAgentRun)
|
||||
mux.HandleFunc("GET /api/v1/runs/{runId}", s.handleRunGet)
|
||||
return 2
|
||||
}
|
||||
|
||||
/* ── Request and response ───────────────────────────────────────────────── */
|
||||
|
||||
// runRequest is what a client sends to run an agent.
|
||||
type runRequest struct {
|
||||
// Input is the caller's question. Required.
|
||||
Input string `json:"input"`
|
||||
|
||||
// AgentVersion pins the run to a published version.
|
||||
//
|
||||
// A client resuming a conversation sends the version the FIRST answer came
|
||||
// back with — every response carries it — so the conversation stays on the
|
||||
// agent it started with even if somebody publishes an edit mid-thread. Zero
|
||||
// or absent means whatever is current, which is what a fresh question wants.
|
||||
//
|
||||
// It matters most on an approval: a person approved a write while looking
|
||||
// at one version, and carrying it out under a newer one would perform
|
||||
// something they were never shown.
|
||||
AgentVersion int `json:"agentVersion,omitempty"`
|
||||
|
||||
// Confirmation is a token a person approved, carried into a resumed run.
|
||||
//
|
||||
// It authorises ONE call — the exact tool and arguments it was issued
|
||||
// against — and supplying it does not put the run into a permissive mode. A
|
||||
// second write in the same run raises its own confirmation, because a
|
||||
// person approved one thing. See tools/confirm.go.
|
||||
Confirmation string `json:"confirmation,omitempty"`
|
||||
|
||||
// Context is opaque client state passed to the runtime. Never used for
|
||||
// authorization: the principal comes from the session, always.
|
||||
Context map[string]any `json:"context,omitempty"`
|
||||
}
|
||||
|
||||
// runResponse is what comes back.
|
||||
//
|
||||
// Deliberately not the ExecutionResult. That struct carries a Go `error` and
|
||||
// internal wording; this one carries a code and a sentence written for a
|
||||
// person, which is the §10 boundary made concrete.
|
||||
type runResponse struct {
|
||||
RunID string `json:"runId"`
|
||||
AgentID string `json:"agentId"`
|
||||
Version int `json:"agentVersion,omitempty"`
|
||||
Termination string `json:"termination"`
|
||||
|
||||
// Output is the assistant's text. Present on a completed run, and also on a
|
||||
// bounded one — a run that hit its deadline mid-sentence still said
|
||||
// something, and throwing it away helps nobody.
|
||||
Output string `json:"output,omitempty"`
|
||||
|
||||
// Message is what to show a person when the run did not complete. Derived
|
||||
// here from the termination, never raised from the core.
|
||||
Message string `json:"message,omitempty"`
|
||||
|
||||
// Confirmations are writes the agent proposed and did not perform. Present
|
||||
// exactly when termination is ConfirmationPending.
|
||||
Confirmations []*tools.Confirmation `json:"confirmations,omitempty"`
|
||||
|
||||
Usage runUsage `json:"usage"`
|
||||
}
|
||||
|
||||
// runUsage is the token accounting, flattened for the client.
|
||||
type runUsage struct {
|
||||
InputTokens int64 `json:"inputTokens"`
|
||||
OutputTokens int64 `json:"outputTokens"`
|
||||
CachedTokens int64 `json:"cachedTokens"`
|
||||
TotalTokens int64 `json:"totalTokens"`
|
||||
ModelCalls int `json:"modelCalls"`
|
||||
}
|
||||
|
||||
/* ── Running an agent ───────────────────────────────────────────────────── */
|
||||
|
||||
// handleAgentRun executes one agent turn.
|
||||
func (s *Server) handleAgentRun(w http.ResponseWriter, r *http.Request) {
|
||||
ident, err := authctx.MustFrom(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, s.log, domain.Internal(err))
|
||||
return
|
||||
}
|
||||
|
||||
var req runRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRunRequestBytes)).Decode(&req); err != nil {
|
||||
writeError(w, s.log, domain.Validation("the request body was not valid JSON", nil))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Input) == "" {
|
||||
writeError(w, s.log, domain.Validation("a run needs an input", map[string]string{
|
||||
"input": "required",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// Streamed when the client asks for it, by Accept rather than by a second
|
||||
// route. It is the same run with the same semantics — the same principal,
|
||||
// the same budgets, the same confirmation gate — delivered differently. Two
|
||||
// routes would be two things to keep in step, and the one that drifted
|
||||
// would be the one nobody tested.
|
||||
if wantsSSE(r) {
|
||||
s.streamAgentRun(w, r, ident, req)
|
||||
return
|
||||
}
|
||||
|
||||
// The principal is the SESSION's, never the body's. I1 begins here: a
|
||||
// client that could name its own principal could read anything.
|
||||
res, runErr := s.agents.RunAgent(r.Context(), ident, r.PathValue("id"), runtime.ExecutionInput{
|
||||
Identity: ident,
|
||||
Input: req.Input,
|
||||
AgentVersion: req.AgentVersion,
|
||||
Confirmation: req.Confirmation,
|
||||
Context: req.Context,
|
||||
})
|
||||
|
||||
// A load failure — no such agent, not this tenant's, draft, archived — is a
|
||||
// resource error and answers like one. It is distinguishable from a run
|
||||
// that started and ended badly, which is the distinction below.
|
||||
if res == nil || res.Termination == "" {
|
||||
writeError(w, s.log, runLoadError(runErr))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, buildRunResponse(res))
|
||||
}
|
||||
|
||||
// buildRunResponse turns a runtime result into the client's shape.
|
||||
//
|
||||
// Every termination answers 200. That looks wrong at first and is not: the
|
||||
// question "did the HTTP request succeed" and the question "did the agent
|
||||
// finish" are different questions, and collapsing them costs the client the
|
||||
// second one. A run that hit its budget is a run — it has an id, a trajectory,
|
||||
// a token cost and often a partial answer — and answering 500 would throw all
|
||||
// of that away while telling the client to retry something that will fail the
|
||||
// same way.
|
||||
func buildRunResponse(res *runtime.ExecutionResult) runResponse {
|
||||
out := runResponse{
|
||||
RunID: res.RunID,
|
||||
AgentID: res.AgentID,
|
||||
Version: res.AgentVersion,
|
||||
Termination: string(res.Termination),
|
||||
Output: res.Output,
|
||||
Confirmations: res.Confirmations,
|
||||
Usage: runUsage{
|
||||
InputTokens: res.Usage.InputTokens,
|
||||
OutputTokens: res.Usage.OutputTokens,
|
||||
CachedTokens: res.Usage.CachedTokens,
|
||||
TotalTokens: res.Usage.TotalTokens,
|
||||
ModelCalls: res.Usage.ModelCalls,
|
||||
},
|
||||
}
|
||||
if res.Termination != runtime.TerminationCompleted {
|
||||
out.Message = terminationMessage(res.Termination)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// terminationMessage is the user-facing wording for each termination.
|
||||
//
|
||||
// §10's boundary, and the reason it lives here rather than in the runtime: the
|
||||
// core's terminationMessage is an internal explanation for a log, and this one
|
||||
// is a sentence a venue manager reads. They differ on purpose — "the run
|
||||
// reached its budget before finishing" is accurate and means nothing to
|
||||
// somebody who has never heard of a token budget.
|
||||
//
|
||||
// Every one of the six is spelled out. A default that said "something went
|
||||
// wrong" would be the place where a Refused run and a ToolFailure became
|
||||
// indistinguishable to the person best placed to tell us which it was.
|
||||
func terminationMessage(t runtime.Termination) string {
|
||||
switch t {
|
||||
case runtime.TerminationCompleted:
|
||||
return ""
|
||||
case runtime.TerminationBudgetExceeded:
|
||||
return "This question needed more work than the agent is allowed to spend in one go. " +
|
||||
"Try asking for a narrower slice of it."
|
||||
case runtime.TerminationDeadline:
|
||||
return "The agent ran out of time before finishing. Anything it had already worked out is above."
|
||||
case runtime.TerminationConfirmationPending:
|
||||
return "The agent has proposed a change and is waiting for you to approve it."
|
||||
case runtime.TerminationToolFailure:
|
||||
return "The agent could not finish — something it needed did not answer. " +
|
||||
"Nothing was changed."
|
||||
case runtime.TerminationRefused:
|
||||
return "The agent declined to answer this one."
|
||||
default:
|
||||
return "The agent did not finish."
|
||||
}
|
||||
}
|
||||
|
||||
// runLoadError maps a pre-run failure onto the API's error vocabulary.
|
||||
//
|
||||
// These are the errors from LoadExecutableAgent, raised before any run began —
|
||||
// so there is no run id, no trajectory and no termination. They are resource
|
||||
// errors and answer like resource errors.
|
||||
//
|
||||
// ErrNotFound and ErrUnauthorized deliberately both become 404. §8's rule about
|
||||
// denials applies to agents as much as to rows: "this agent exists but is not
|
||||
// yours" and "there is no such agent" must not be distinguishable, or the
|
||||
// endpoint becomes a way to enumerate other tenants' agents one id at a time.
|
||||
func runLoadError(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return domain.Internal(errors.New("the run produced no result and no error"))
|
||||
case errors.Is(err, runtime.ErrNotFound), errors.Is(err, runtime.ErrUnauthorized):
|
||||
return domain.NotFound("agent", "")
|
||||
case errors.Is(err, runtime.ErrDraftAgent):
|
||||
return domain.Validation("this agent is still a draft and cannot be run", nil)
|
||||
case errors.Is(err, runtime.ErrArchivedAgent):
|
||||
return domain.Validation("this agent is archived and cannot be run", nil)
|
||||
case errors.Is(err, runtime.ErrNotExecutable),
|
||||
errors.Is(err, runtime.ErrInvalidDefinition):
|
||||
return domain.Validation("this agent is not in a runnable state", nil)
|
||||
case errors.Is(err, runtime.ErrDependencyMissing),
|
||||
errors.Is(err, runtime.ErrDependencyInactive),
|
||||
errors.Is(err, runtime.ErrCircularDependency):
|
||||
return domain.Validation("this agent depends on a skill that is missing or inactive", nil)
|
||||
default:
|
||||
return domain.Internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// maxRunRequestBytes bounds a run request body.
|
||||
//
|
||||
// A question, not a document. Retrieval is how a corpus reaches the model, and
|
||||
// it goes through the permission layer; a client posting a megabyte of text
|
||||
// would be routing around that — the text would land in the prompt having been
|
||||
// read by nobody and authorized by nothing.
|
||||
const maxRunRequestBytes = 64 << 10
|
||||
|
||||
/* ── Reading a trajectory ───────────────────────────────────────────────── */
|
||||
|
||||
// handleRunGet returns a recorded run.
|
||||
//
|
||||
// §6 requires a full trajectory per run, and this is what makes it worth
|
||||
// having: "why did the agent say that" is answerable by a support conversation
|
||||
// pointing at a run id.
|
||||
//
|
||||
// Tenant-scoped by the store, not by this handler. I5 — the predicate lives in
|
||||
// the query, so a run id from another organization is simply absent and answers
|
||||
// 404, indistinguishable from one that never existed.
|
||||
func (s *Server) handleRunGet(w http.ResponseWriter, r *http.Request) {
|
||||
ident, err := authctx.MustFrom(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, s.log, domain.Internal(err))
|
||||
return
|
||||
}
|
||||
|
||||
traj, err := s.runs.Load(r.Context(), ident, r.PathValue("runId"))
|
||||
if err != nil {
|
||||
writeError(w, s.log, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, traj)
|
||||
}
|
||||
|
||||
/* ── Streaming ──────────────────────────────────────────────────────────── */
|
||||
|
||||
// wantsSSE reports whether the client asked for a streamed response.
|
||||
func wantsSSE(r *http.Request) bool {
|
||||
return strings.Contains(r.Header.Get("Accept"), "text/event-stream")
|
||||
}
|
||||
|
||||
// streamAgentRun runs an agent, sending text as it arrives.
|
||||
//
|
||||
// The wire format is one JSON object per SSE event, which is the same shape the
|
||||
// non-streaming response uses for its parts:
|
||||
//
|
||||
// {"delta": "…"} assistant text, as the model produces it
|
||||
// {"run": { … }} the finished run — termination, confirmations, usage
|
||||
// {"error": { … }} a run that could not start
|
||||
//
|
||||
// The final `run` event carries the SAME body the non-streaming path returns.
|
||||
// That is what keeps the two honest: a client can ignore every delta, read only
|
||||
// the last event, and be in exactly the state it would have been in without
|
||||
// streaming.
|
||||
func (s *Server) streamAgentRun(w http.ResponseWriter, r *http.Request, ident authctx.Identity, req runRequest) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
// Something between here and the client buffers. Streaming into it
|
||||
// would deliver the whole answer at the end anyway, but silently — so
|
||||
// the honest move is to answer normally rather than pretend.
|
||||
res, runErr := s.agents.RunAgent(r.Context(), ident, r.PathValue("id"), runtime.ExecutionInput{
|
||||
Identity: ident, Input: req.Input,
|
||||
AgentVersion: req.AgentVersion,
|
||||
Confirmation: req.Confirmation, Context: req.Context,
|
||||
})
|
||||
if res == nil || res.Termination == "" {
|
||||
writeError(w, s.log, runLoadError(runErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, buildRunResponse(res))
|
||||
return
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-store")
|
||||
// Nginx and friends buffer proxied responses by default, which turns a
|
||||
// stream into one very late blob. This is the header that turns that off.
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
send := func(payload any) {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", encoded)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
res, runErr := s.agents.RunAgent(r.Context(), ident, r.PathValue("id"), runtime.ExecutionInput{
|
||||
Identity: ident,
|
||||
Input: req.Input,
|
||||
AgentVersion: req.AgentVersion,
|
||||
Confirmation: req.Confirmation,
|
||||
Context: req.Context,
|
||||
OnDelta: func(d string) { send(map[string]string{"delta": d}) },
|
||||
})
|
||||
|
||||
// A load failure has no run to report. It is sent as an event rather than a
|
||||
// status code, because the status was already written when the stream
|
||||
// opened — an SSE response cannot change its mind about being a 200.
|
||||
if res == nil || res.Termination == "" {
|
||||
var de *domain.Error
|
||||
err := runLoadError(runErr)
|
||||
if errors.As(err, &de) {
|
||||
send(map[string]any{"error": map[string]string{"code": de.Code, "message": de.Message}})
|
||||
} else {
|
||||
send(map[string]any{"error": map[string]string{"code": "internal", "message": "internal error"}})
|
||||
}
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
send(map[string]any{"run": buildRunResponse(res)})
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
632
go-api/internal/httpserver/runs_test.go
Normal file
632
go-api/internal/httpserver/runs_test.go
Normal file
@@ -0,0 +1,632 @@
|
||||
package httpserver_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/gateway"
|
||||
"github.com/krow/krow-backend/go-api/internal/httpserver"
|
||||
"github.com/krow/krow-backend/go-api/internal/runtime"
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// The run endpoint's tests.
|
||||
//
|
||||
// Everything here is about the SEAM rather than the runtime — the runtime has
|
||||
// its own tests and they are thorough. What this file asks is the set of
|
||||
// questions only the HTTP layer can answer:
|
||||
//
|
||||
// - Does an unauthenticated caller get in?
|
||||
// - Does another tenant's agent look absent or forbidden? (It must look
|
||||
// absent — a 403 is a confirmation that the agent exists.)
|
||||
// - Does a bounded run answer like a failure or like a run?
|
||||
// - Does a pending confirmation reach the client in a shape it can act on?
|
||||
// - Can one worker read another's trajectory?
|
||||
//
|
||||
// The model is scripted throughout. That is not a compromise: this file is
|
||||
// about status codes and response shapes, and a live model would make it slow,
|
||||
// non-deterministic and impossible to run without a credential.
|
||||
|
||||
/* ── Fixtures ───────────────────────────────────────────────────────────── */
|
||||
|
||||
// stubGateway answers with whatever it was given.
|
||||
type stubGateway struct {
|
||||
text string
|
||||
calls []gateway.ToolCall
|
||||
err error
|
||||
sent int
|
||||
}
|
||||
|
||||
func (s *stubGateway) Complete(_ context.Context, _ gateway.Request) (*gateway.Response, error) {
|
||||
s.sent++
|
||||
if s.err != nil {
|
||||
return &gateway.Response{Model: "stub"}, s.err
|
||||
}
|
||||
if len(s.calls) > 0 && s.sent == 1 {
|
||||
return &gateway.Response{
|
||||
ToolCalls: s.calls, StopReason: "tool_use", Model: "stub",
|
||||
Usage: gateway.Usage{InputTokens: 400, OutputTokens: 30},
|
||||
}, nil
|
||||
}
|
||||
return &gateway.Response{
|
||||
Text: s.text, StopReason: "end_turn", Model: "stub",
|
||||
Usage: gateway.Usage{InputTokens: 500, OutputTokens: 40},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// publishAgent writes a runnable agent definition.
|
||||
func publishAgent(t *testing.T, pool *pgxpool.Pool, orgID, userID, id string, toolNames ...string) {
|
||||
t.Helper()
|
||||
var toolBlock string
|
||||
if len(toolNames) > 0 {
|
||||
toolBlock = "tools:\n"
|
||||
for _, n := range toolNames {
|
||||
toolBlock += " - " + n + "\n"
|
||||
}
|
||||
}
|
||||
md := fmt.Sprintf(`---
|
||||
id: %s
|
||||
name: Test Agent
|
||||
description: An agent for the run endpoint's tests
|
||||
status: published
|
||||
version: 1
|
||||
pages:
|
||||
- control-center
|
||||
reasoning: balanced
|
||||
%s---
|
||||
|
||||
## Instructions
|
||||
Answer the question.
|
||||
`, id, toolBlock)
|
||||
|
||||
if _, err := pool.Exec(context.Background(), `
|
||||
INSERT INTO agent_definitions
|
||||
(definition_id, org_id, visibility, created_by, markdown, status, version, name, description, pages)
|
||||
VALUES ($1::text, $2::uuid, 'organization', $3::uuid, $4::text, 'published', 1,
|
||||
'Test Agent', 'An agent for tests', ARRAY['control-center'])`,
|
||||
id, orgID, userID, md); err != nil {
|
||||
t.Fatalf("publish agent %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedOrgAdmin creates a fresh tenant and an admin user inside it.
|
||||
//
|
||||
// A tenant per test, not the seeded one. The cross-tenant assertions below need
|
||||
// two organizations that genuinely do not know about each other, and reusing
|
||||
// the fixture's org for one of them would make "another tenant" mean "the same
|
||||
// tenant with a different user".
|
||||
func seedOrgAdmin(t *testing.T, h *testutil.Harness) (orgID, userID string) {
|
||||
t.Helper()
|
||||
slug := fmt.Sprintf("runs-%d-%s", orgCounter.Add(1), t.Name())
|
||||
slug = strings.ToLower(strings.NewReplacer("/", "-", "_", "-", " ", "-").Replace(slug))
|
||||
if len(slug) > 60 {
|
||||
slug = slug[:60]
|
||||
}
|
||||
if err := h.Pool.QueryRow(context.Background(),
|
||||
`INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id::text`,
|
||||
slug, slug).Scan(&orgID); err != nil {
|
||||
t.Fatalf("create org: %v", err)
|
||||
}
|
||||
userID = newUserWithRole(t, h.Pool, orgID,
|
||||
fmt.Sprintf("owner-%s@runs.test", slug), "admin")
|
||||
return orgID, userID
|
||||
}
|
||||
|
||||
// orgCounter keeps fixture slugs unique. Emails and slugs are globally unique,
|
||||
// so two tenants in one test collide without it.
|
||||
var orgCounter atomic.Int64
|
||||
|
||||
// runServer builds a server whose runtime is driven by a scripted gateway.
|
||||
func runServer(t *testing.T, h *testutil.Harness, gw gateway.Gateway, reg *tools.Registry) *httpserver.Server {
|
||||
t.Helper()
|
||||
engine := runtime.NewEngine(h.Pool, runtime.WithAgentExecutor(
|
||||
runtime.NewModelExecutor(gw, runtime.NewPostgresSink(h.Pool), reg),
|
||||
))
|
||||
return newServer(t, h, nil, httpserver.WithAgentEngine(engine))
|
||||
}
|
||||
|
||||
// postRun calls the run endpoint as one actor.
|
||||
func postRun(t *testing.T, handler http.Handler, a actor, agentID, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest("POST",
|
||||
"/api/v1/agents/"+agentID+"/runs", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if a.cookie != nil {
|
||||
req.AddCookie(a.cookie)
|
||||
}
|
||||
return doJSON(t, handler, req)
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, handler http.Handler, req *http.Request) (int, map[string]any) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
var body map[string]any
|
||||
if rec.Body.Len() > 0 {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response was not JSON: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
return rec.Code, body
|
||||
}
|
||||
|
||||
/* ── The endpoint exists at all ─────────────────────────────────────────── */
|
||||
|
||||
func TestTheRunRoutesAreAbsentWithoutARuntime(t *testing.T) {
|
||||
// A deployment with no model credential does not serve agents. Registering
|
||||
// the routes anyway would accept runs and fail every one at the gateway —
|
||||
// an outage shaped like a feature. 404 says "this deployment does not do
|
||||
// that", which is true; 500 would say "this deployment is broken", which is
|
||||
// not.
|
||||
h := testutil.New(t)
|
||||
srv := newServer(t, h, nil) // no WithAgentEngine, no API key
|
||||
handler := srv.Handler()
|
||||
|
||||
orgID, adminID := seedOrgAdmin(t, h)
|
||||
publishAgent(t, h.Pool, orgID, adminID, "test-agent")
|
||||
admin := signInAs(t, handler, h.Pool, orgID, "admin", "admin@runs.test", "admin")
|
||||
|
||||
code, _ := postRun(t, handler, admin, "test-agent", `{"input":"hello"}`)
|
||||
if code != http.StatusNotFound {
|
||||
t.Errorf("status %d without a runtime, want 404", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnauthenticatedRunIsRefused(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
srv := runServer(t, h, &stubGateway{text: "hello"}, nil)
|
||||
handler := srv.Handler()
|
||||
|
||||
code, _ := postRun(t, handler, actor{}, "test-agent", `{"input":"hello"}`)
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Errorf("status %d for an unauthenticated run, want 401", code)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── A completed run ────────────────────────────────────────────────────── */
|
||||
|
||||
func TestACompletedRunAnswersWithItsOutputAndCost(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
srv := runServer(t, h, &stubGateway{text: "Twelve events, mostly logins."}, nil)
|
||||
handler := srv.Handler()
|
||||
|
||||
orgID, adminID := seedOrgAdmin(t, h)
|
||||
publishAgent(t, h.Pool, orgID, adminID, "test-agent")
|
||||
admin := signInAs(t, handler, h.Pool, orgID, "admin", "admin@runs.test", "admin")
|
||||
|
||||
code, body := postRun(t, handler, admin, "test-agent", `{"input":"what happened?"}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status %d: %v", code, body)
|
||||
}
|
||||
if body["termination"] != "Completed" {
|
||||
t.Errorf("termination = %v, want Completed", body["termination"])
|
||||
}
|
||||
if body["output"] != "Twelve events, mostly logins." {
|
||||
t.Errorf("output = %v", body["output"])
|
||||
}
|
||||
if body["runId"] == nil || body["runId"] == "" {
|
||||
t.Error("a run came back with no id; nothing can point at its trajectory")
|
||||
}
|
||||
// Token accounting reaches the client. A caller paying for runs should be
|
||||
// able to see what one cost without reading a log.
|
||||
usage, _ := body["usage"].(map[string]any)
|
||||
if usage == nil || usage["totalTokens"] == nil {
|
||||
t.Errorf("no usage in the response: %v", body)
|
||||
}
|
||||
// A completed run carries no user-facing message: the output IS the answer.
|
||||
if msg, ok := body["message"].(string); ok && msg != "" {
|
||||
t.Errorf("a completed run carried a message: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The denial rules ───────────────────────────────────────────────────── */
|
||||
|
||||
func TestAnotherTenantsAgentIsAbsentRatherThanForbidden(t *testing.T) {
|
||||
// §8's rule about denials applies to agents as much as to rows. If "exists
|
||||
// but not yours" answered 403 and "no such agent" answered 404, the
|
||||
// endpoint would be a way to enumerate other tenants' agents one id at a
|
||||
// time — and the agent would happily run that enumeration.
|
||||
h := testutil.New(t)
|
||||
srv := runServer(t, h, &stubGateway{text: "hello"}, nil)
|
||||
handler := srv.Handler()
|
||||
|
||||
mine, mineAdmin := seedOrgAdmin(t, h)
|
||||
theirs, theirsAdmin := seedOrgAdmin(t, h)
|
||||
publishAgent(t, h.Pool, theirs, theirsAdmin, "their-agent")
|
||||
_ = mineAdmin
|
||||
|
||||
admin := signInAs(t, handler, h.Pool, mine, "admin", "admin@mine.test", "admin")
|
||||
|
||||
real, realBody := postRun(t, handler, admin, "their-agent", `{"input":"hi"}`)
|
||||
fake, fakeBody := postRun(t, handler, admin, "no-such-agent-at-all", `{"input":"hi"}`)
|
||||
|
||||
if real != http.StatusNotFound {
|
||||
t.Errorf("another tenant's agent answered %d, want 404", real)
|
||||
}
|
||||
if fake != http.StatusNotFound {
|
||||
t.Errorf("an imaginary agent answered %d, want 404", fake)
|
||||
}
|
||||
if fmt.Sprint(realBody) != fmt.Sprint(fakeBody) {
|
||||
t.Errorf("a real-but-forbidden agent is distinguishable from an imaginary one:\n"+
|
||||
" theirs: %v\n invented: %v", realBody, fakeBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunWithNoInputIsRefused(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
srv := runServer(t, h, &stubGateway{text: "hello"}, nil)
|
||||
handler := srv.Handler()
|
||||
|
||||
orgID, adminID := seedOrgAdmin(t, h)
|
||||
publishAgent(t, h.Pool, orgID, adminID, "test-agent")
|
||||
admin := signInAs(t, handler, h.Pool, orgID, "admin", "admin@runs.test", "admin")
|
||||
|
||||
code, _ := postRun(t, handler, admin, "test-agent", `{}`)
|
||||
if code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("status %d for an empty input, want 422", code)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── A pending confirmation ─────────────────────────────────────────────── */
|
||||
|
||||
func TestAPendingConfirmationReachesTheClientAsAQuestionNotAnError(t *testing.T) {
|
||||
// I4 arriving at the surface. A run waiting on a person is not a failure:
|
||||
// it has an id, a cost, a trajectory and a payload somebody has to read.
|
||||
// Answering it 500 would make the whole write path look broken, and the
|
||||
// client would have no token to call back with.
|
||||
h := testutil.New(t)
|
||||
|
||||
var wrote int
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(tools.Tool{
|
||||
Name: "assign_worker", Description: "Assigns somebody to something, for this test.",
|
||||
InputSchema: map[string]any{"type": "object"}, Effect: tools.EffectWrite,
|
||||
Confirm: func(context.Context, tools.Context, json.RawMessage) (*tools.Confirmation, *tools.Result) {
|
||||
return &tools.Confirmation{
|
||||
Title: "Assign Maya Chen to Bar Supervisor",
|
||||
Summary: "Maya Chen will be scheduled to work Friday evening.",
|
||||
}, nil
|
||||
},
|
||||
Handler: func(context.Context, tools.Context, json.RawMessage) tools.Result {
|
||||
wrote++
|
||||
return tools.OK(map[string]any{"ok": true})
|
||||
},
|
||||
})
|
||||
|
||||
gw := &stubGateway{
|
||||
text: "done",
|
||||
calls: []gateway.ToolCall{{ID: "c1", Name: "assign_worker", Input: json.RawMessage(`{}`)}},
|
||||
}
|
||||
srv := runServer(t, h, gw, reg)
|
||||
handler := srv.Handler()
|
||||
|
||||
orgID, adminID := seedOrgAdmin(t, h)
|
||||
publishAgent(t, h.Pool, orgID, adminID, "cover-agent", "assign_worker")
|
||||
admin := signInAs(t, handler, h.Pool, orgID, "admin", "admin@runs.test", "admin")
|
||||
|
||||
code, body := postRun(t, handler, admin, "cover-agent", `{"input":"cover Friday"}`)
|
||||
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status %d for a pending confirmation, want 200: %v", code, body)
|
||||
}
|
||||
if body["termination"] != "ConfirmationPending" {
|
||||
t.Fatalf("termination = %v, want ConfirmationPending", body["termination"])
|
||||
}
|
||||
if wrote != 0 {
|
||||
t.Fatalf("the write ran %d times without an approval", wrote)
|
||||
}
|
||||
|
||||
confirmations, _ := body["confirmations"].([]any)
|
||||
if len(confirmations) != 1 {
|
||||
t.Fatalf("%d confirmations in the response, want 1: %v", len(confirmations), body)
|
||||
}
|
||||
c, _ := confirmations[0].(map[string]any)
|
||||
if c["token"] == nil || c["token"] == "" {
|
||||
t.Error("the confirmation has no token; the client can never answer it")
|
||||
}
|
||||
if c["title"] == nil || c["title"] == "" {
|
||||
t.Error("the confirmation has nothing written on it for a person to read")
|
||||
}
|
||||
// And the client is told what to say to the user, derived here rather than
|
||||
// raised from the core.
|
||||
if msg, _ := body["message"].(string); !strings.Contains(strings.ToLower(msg), "approve") {
|
||||
t.Errorf("message = %q; it should tell the user an approval is needed", msg)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reading a trajectory ───────────────────────────────────────────────── */
|
||||
|
||||
func TestATrajectoryIsReadableByItsOwnerAndNobodyElse(t *testing.T) {
|
||||
// A trajectory holds the question that was asked and the records retrieved
|
||||
// to answer it. "Anyone in the tenant may read any run" would let every
|
||||
// worker read every colleague's conversation with an agent — including the
|
||||
// ones about them.
|
||||
h := testutil.New(t)
|
||||
srv := runServer(t, h, &stubGateway{text: "an answer"}, nil)
|
||||
handler := srv.Handler()
|
||||
|
||||
orgID, adminID := seedOrgAdmin(t, h)
|
||||
publishAgent(t, h.Pool, orgID, adminID, "test-agent")
|
||||
|
||||
maya := signInAs(t, handler, h.Pool, orgID, "maya", "maya@runs.test", "talent")
|
||||
dan := signInAs(t, handler, h.Pool, orgID, "dan", "dan@runs.test", "talent")
|
||||
admin := signInAs(t, handler, h.Pool, orgID, "admin", "admin@runs.test", "admin")
|
||||
|
||||
code, body := postRun(t, handler, maya, "test-agent", `{"input":"my private question"}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status %d: %v", code, body)
|
||||
}
|
||||
runID, _ := body["runId"].(string)
|
||||
if runID == "" {
|
||||
t.Fatal("no run id came back")
|
||||
}
|
||||
|
||||
get := func(a actor) (int, map[string]any) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/runs/"+runID, nil)
|
||||
if a.cookie != nil {
|
||||
req.AddCookie(a.cookie)
|
||||
}
|
||||
return doJSON(t, handler, req)
|
||||
}
|
||||
|
||||
if code, _ := get(maya); code != http.StatusOK {
|
||||
t.Errorf("the owner could not read their own run: %d", code)
|
||||
}
|
||||
if code, _ := get(dan); code != http.StatusNotFound {
|
||||
t.Errorf("another worker read a colleague's run: %d, want 404", code)
|
||||
}
|
||||
// An operator sees the organization's runs. That is what an operator
|
||||
// console is, and it is the same reach the policy table already gives them
|
||||
// over every other resource.
|
||||
if code, _ := get(admin); code != http.StatusOK {
|
||||
t.Errorf("an operator could not read their organization's run: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATrajectoryFromAnotherTenantIsAbsent(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
srv := runServer(t, h, &stubGateway{text: "an answer"}, nil)
|
||||
handler := srv.Handler()
|
||||
|
||||
mine, mineAdmin := seedOrgAdmin(t, h)
|
||||
theirs, _ := seedOrgAdmin(t, h)
|
||||
publishAgent(t, h.Pool, mine, mineAdmin, "test-agent")
|
||||
|
||||
owner := signInAs(t, handler, h.Pool, mine, "owner", "owner@mine.test", "admin")
|
||||
outsider := signInAs(t, handler, h.Pool, theirs, "outsider", "outsider@theirs.test", "admin")
|
||||
|
||||
_, body := postRun(t, handler, owner, "test-agent", `{"input":"a question"}`)
|
||||
runID, _ := body["runId"].(string)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/runs/"+runID, nil)
|
||||
req.AddCookie(outsider.cookie)
|
||||
code, _ := doJSON(t, handler, req)
|
||||
|
||||
if code != http.StatusNotFound {
|
||||
t.Errorf("another tenant read a run: %d, want 404", code)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Streaming ──────────────────────────────────────────────────────────── */
|
||||
|
||||
// streamingStub is a gateway that emits text in pieces.
|
||||
type streamingStub struct {
|
||||
pieces []string
|
||||
deltas int
|
||||
}
|
||||
|
||||
func (s *streamingStub) Complete(context.Context, gateway.Request) (*gateway.Response, error) {
|
||||
return &gateway.Response{
|
||||
Text: strings.Join(s.pieces, ""), StopReason: "end_turn", Model: "stub",
|
||||
Usage: gateway.Usage{InputTokens: 100, OutputTokens: 20},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *streamingStub) Stream(_ context.Context, _ gateway.Request, onDelta func(string)) (*gateway.Response, error) {
|
||||
for _, p := range s.pieces {
|
||||
s.deltas++
|
||||
onDelta(p)
|
||||
}
|
||||
return &gateway.Response{
|
||||
Text: strings.Join(s.pieces, ""), StopReason: "end_turn", Model: "stub",
|
||||
Usage: gateway.Usage{InputTokens: 100, OutputTokens: 20},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sseEvents pulls the JSON payloads out of an SSE body.
|
||||
func sseEvents(t *testing.T, body string) []map[string]any {
|
||||
t.Helper()
|
||||
var out []map[string]any
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(line[5:])
|
||||
if payload == "" || payload == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var e map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &e); err != nil {
|
||||
t.Fatalf("event was not JSON: %s", payload)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAStreamedRunDeliversTextThenTheFinishedRun(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
gw := &streamingStub{pieces: []string{"Twelve ", "events, ", "mostly logins."}}
|
||||
srv := runServer(t, h, gw, nil)
|
||||
handler := srv.Handler()
|
||||
|
||||
orgID, adminID := seedOrgAdmin(t, h)
|
||||
publishAgent(t, h.Pool, orgID, adminID, "test-agent")
|
||||
admin := signInAs(t, handler, h.Pool, orgID, "admin", "admin@stream.test", "admin")
|
||||
|
||||
req, _ := http.NewRequest("POST", "/api/v1/agents/test-agent/runs",
|
||||
strings.NewReader(`{"input":"what happened?"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.AddCookie(admin.cookie)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
|
||||
t.Fatalf("Content-Type is %q, want an event stream — the response did not stream", ct)
|
||||
}
|
||||
|
||||
events := sseEvents(t, rec.Body.String())
|
||||
var deltas []string
|
||||
var final map[string]any
|
||||
for _, e := range events {
|
||||
if d, ok := e["delta"].(string); ok {
|
||||
deltas = append(deltas, d)
|
||||
}
|
||||
if r, ok := e["run"].(map[string]any); ok {
|
||||
final = r
|
||||
}
|
||||
}
|
||||
|
||||
if len(deltas) != 3 {
|
||||
t.Errorf("%d text deltas, want 3 — the text arrived in one piece", len(deltas))
|
||||
}
|
||||
if strings.Join(deltas, "") != "Twelve events, mostly logins." {
|
||||
t.Errorf("the deltas do not reassemble into the answer: %q", strings.Join(deltas, ""))
|
||||
}
|
||||
|
||||
// The property that keeps the two paths honest: a client that ignored every
|
||||
// delta and read only the last event is where it would have been without
|
||||
// streaming at all.
|
||||
if final == nil {
|
||||
t.Fatal("no final run event; a client reading only the last event would have nothing")
|
||||
}
|
||||
if final["termination"] != "Completed" {
|
||||
t.Errorf("final termination = %v", final["termination"])
|
||||
}
|
||||
if final["output"] != "Twelve events, mostly logins." {
|
||||
t.Errorf("final output = %v", final["output"])
|
||||
}
|
||||
if final["runId"] == nil || final["runId"] == "" {
|
||||
t.Error("the final event carries no run id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareDoesNotSwallowFlush(t *testing.T) {
|
||||
// The bug this pins cost an hour and produced no error anywhere.
|
||||
//
|
||||
// Two middlewares wrap the ResponseWriter to record a status and to
|
||||
// intercept the mux's plain-text 404s. Both embed http.ResponseWriter,
|
||||
// which inherits Write and WriteHeader and SILENTLY DROPS every optional
|
||||
// interface underneath — Flusher among them. The SSE handler asked "can
|
||||
// this flush?", was told no, and fell back to ordinary JSON: a correct,
|
||||
// complete, entirely non-streaming response with nothing to indicate that
|
||||
// streaming had been requested and quietly refused.
|
||||
//
|
||||
// Asserted through the whole middleware stack, because testing the handler
|
||||
// alone is exactly what missed it.
|
||||
h := testutil.New(t)
|
||||
srv := runServer(t, h, &streamingStub{pieces: []string{"a", "b"}}, nil)
|
||||
handler := srv.Handler()
|
||||
|
||||
orgID, adminID := seedOrgAdmin(t, h)
|
||||
publishAgent(t, h.Pool, orgID, adminID, "test-agent")
|
||||
admin := signInAs(t, handler, h.Pool, orgID, "admin", "admin@flush.test", "admin")
|
||||
|
||||
req, _ := http.NewRequest("POST", "/api/v1/agents/test-agent/runs",
|
||||
strings.NewReader(`{"input":"hi"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.AddCookie(admin.cookie)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
|
||||
t.Fatalf("Content-Type is %q — a wrapper dropped Flusher and the stream fell back to JSON", ct)
|
||||
}
|
||||
if b := rec.Header().Get("X-Accel-Buffering"); b != "no" {
|
||||
t.Errorf("X-Accel-Buffering is %q; a buffering proxy will hold the whole stream", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOrdinaryRequestIsStillNotStreamed(t *testing.T) {
|
||||
// Accept decides. A client that did not ask for a stream must not get one —
|
||||
// it would be reading SSE frames as if they were a JSON body.
|
||||
h := testutil.New(t)
|
||||
srv := runServer(t, h, &streamingStub{pieces: []string{"x"}}, nil)
|
||||
handler := srv.Handler()
|
||||
|
||||
orgID, adminID := seedOrgAdmin(t, h)
|
||||
publishAgent(t, h.Pool, orgID, adminID, "test-agent")
|
||||
admin := signInAs(t, handler, h.Pool, orgID, "admin", "admin@plain.test", "admin")
|
||||
|
||||
code, body := postRun(t, handler, admin, "test-agent", `{"input":"hi"}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status %d", code)
|
||||
}
|
||||
if body["termination"] != "Completed" || body["output"] != "x" {
|
||||
t.Errorf("a plain request did not get a plain answer: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARequestedVersionReachesTheRuntime(t *testing.T) {
|
||||
// §3's pin, at the seam. The frontend sends back the version its first
|
||||
// answer carried; this asserts the field survives the request rather than
|
||||
// being quietly dropped — which would look identical from outside until
|
||||
// somebody published an edit mid-conversation.
|
||||
h := testutil.New(t)
|
||||
srv := runServer(t, h, &stubGateway{text: "answered"}, nil)
|
||||
handler := srv.Handler()
|
||||
|
||||
orgID, adminID := seedOrgAdmin(t, h)
|
||||
publishAgent(t, h.Pool, orgID, adminID, "test-agent")
|
||||
admin := signInAs(t, handler, h.Pool, orgID, "admin", "admin@pin.test", "admin")
|
||||
|
||||
// Version 9 has no snapshot, so the run falls back to the current
|
||||
// definition and says so — which is the observable proof the number
|
||||
// travelled: an ignored field would produce no note at all.
|
||||
code, body := postRun(t, handler, admin, "test-agent",
|
||||
`{"input":"hello","agentVersion":9}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status %d: %v", code, body)
|
||||
}
|
||||
if body["termination"] != "Completed" {
|
||||
t.Fatalf("termination = %v", body["termination"])
|
||||
}
|
||||
|
||||
var runID, _ = body["runId"].(string)
|
||||
req, _ := http.NewRequest("GET", "/api/v1/runs/"+runID, nil)
|
||||
req.AddCookie(admin.cookie)
|
||||
_, traj := doJSON(t, handler, req)
|
||||
|
||||
encoded, _ := json.Marshal(traj)
|
||||
if !strings.Contains(string(encoded), "version_unavailable") {
|
||||
t.Errorf("a pinned version with no snapshot left no trace in the trajectory; "+
|
||||
"the field may have been dropped: %s", truncate(string(encoded), 400))
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
133
go-api/internal/httpserver/samesite_test.go
Normal file
133
go-api/internal/httpserver/samesite_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package httpserver_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/config"
|
||||
"github.com/krow/krow-backend/go-api/internal/db"
|
||||
"github.com/krow/krow-backend/go-api/internal/httpserver"
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
)
|
||||
|
||||
// The session cookie's SameSite mode.
|
||||
//
|
||||
// This exists because the mode is a security decision that nothing else in the
|
||||
// suite observes, and because it was silently unreadable for a release: config
|
||||
// parsed and validated HTTP_COOKIE_SAMESITE and no code path consulted it, so
|
||||
// a deployment that set `lax` got `none` and lost its only CSRF protection.
|
||||
//
|
||||
// CORS and SameSite answer different questions. CORS is about ORIGIN;
|
||||
// SameSite is about SITE. A frontend on platform.krowforce.com calling
|
||||
// mcp.krowforce.com is cross-origin — it needs the allowlist — and same-site,
|
||||
// so a Lax cookie reaches it regardless. Deriving None from "an allowlist
|
||||
// exists" is therefore a guess, and these tests pin who gets the final word.
|
||||
|
||||
// sameSiteFor builds a server with the given cookie and CORS configuration and
|
||||
// reports the SameSite attribute it writes. Read off the logout response,
|
||||
// because clearSessionCookie writes the same attributes the login path does and
|
||||
// needs no credentials to reach.
|
||||
func sameSiteFor(t *testing.T, h *testutil.Harness, configured string, origins []string) string {
|
||||
t.Helper()
|
||||
cfg := &config.Config{
|
||||
AppEnv: "production",
|
||||
HTTP: config.HTTPConfig{
|
||||
Host: "127.0.0.1", Port: 0, ShutdownTimeout: time.Second,
|
||||
CookieSameSite: configured,
|
||||
CORSOrigins: origins,
|
||||
},
|
||||
DB: config.DBConfig{Schema: "public"},
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv, err := httpserver.New(cfg, &db.DB{Pool: h.Pool, Schema: "public"}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("build the server: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/auth/logout", nil))
|
||||
|
||||
for _, c := range rec.Header().Values("Set-Cookie") {
|
||||
if !strings.HasPrefix(c, sessionCookie+"=") {
|
||||
continue
|
||||
}
|
||||
for _, part := range strings.Split(c, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if v, ok := strings.CutPrefix(part, "SameSite="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "(absent)"
|
||||
}
|
||||
return "(no cookie)"
|
||||
}
|
||||
|
||||
func TestSessionCookieSameSite(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
origins := []string{"https://platform.krowforce.com"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
configured string
|
||||
origins []string
|
||||
want string
|
||||
}{
|
||||
// The deployment this was written for: CORS is genuinely required
|
||||
// (cross-origin) and Lax is genuinely correct (same-site). Before the
|
||||
// fix this combination was unreachable.
|
||||
{"explicit lax survives a CORS allowlist", "lax", origins, "Lax"},
|
||||
{"explicit none is honoured", "none", nil, "None"},
|
||||
{"explicit strict is honoured", "strict", origins, "Strict"},
|
||||
|
||||
// Unset: the allowlist decides, which is the behaviour b6f8655
|
||||
// introduced and the right default for an unconfigured deployment.
|
||||
{"unset with an allowlist defaults to None", "", origins, "None"},
|
||||
{"unset with no allowlist defaults to Lax", "", nil, "Lax"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := sameSiteFor(t, h, c.configured, c.origins); got != c.want {
|
||||
t.Fatalf("SameSite=%s, want %s", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SameSite=None is meaningless without Secure — browsers reject the pairing
|
||||
// outright, so the cookie would simply never be stored.
|
||||
func TestSameSiteNoneAlwaysCarriesSecure(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
cfg := &config.Config{
|
||||
AppEnv: "development", // Secure would otherwise be off
|
||||
HTTP: config.HTTPConfig{
|
||||
Host: "127.0.0.1", Port: 0, ShutdownTimeout: time.Second,
|
||||
CookieSameSite: "none",
|
||||
},
|
||||
DB: config.DBConfig{Schema: "public"},
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv, err := httpserver.New(cfg, &db.DB{Pool: h.Pool, Schema: "public"}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("build the server: %v", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/auth/logout", nil))
|
||||
|
||||
var cookie string
|
||||
for _, c := range rec.Header().Values("Set-Cookie") {
|
||||
if strings.HasPrefix(c, sessionCookie+"=") {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
if cookie == "" {
|
||||
t.Fatal("no session cookie written")
|
||||
}
|
||||
if !strings.Contains(cookie, "SameSite=None") || !strings.Contains(cookie, "Secure") {
|
||||
t.Fatalf("SameSite=None must be paired with Secure, got %q", cookie)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,10 @@ import (
|
||||
"github.com/krow/krow-backend/go-api/internal/auth"
|
||||
"github.com/krow/krow-backend/go-api/internal/config"
|
||||
"github.com/krow/krow-backend/go-api/internal/db"
|
||||
"github.com/krow/krow-backend/go-api/internal/knowledge"
|
||||
"github.com/krow/krow-backend/go-api/internal/runtime"
|
||||
"github.com/krow/krow-backend/go-api/internal/service"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// Server binds the router, the pool, authentication and the lifecycle together.
|
||||
@@ -46,10 +49,26 @@ type Server struct {
|
||||
definitions *service.DefinitionsService
|
||||
workflows *service.WorkflowService
|
||||
suggestions *service.SuggestionsService
|
||||
log *slog.Logger
|
||||
http *http.Server
|
||||
started time.Time
|
||||
endpoints int
|
||||
|
||||
// The agent runtime. Nil when no model credential is configured — the run
|
||||
// routes are then not registered at all, so the deployment answers 404
|
||||
// ("this deployment does not serve agents") rather than 500 ("this
|
||||
// deployment is broken"). Only one of those is true.
|
||||
agents *runtime.Engine
|
||||
runs *runtime.RunReader
|
||||
version string
|
||||
|
||||
// toolCatalogue is the tool set an agent author may choose from.
|
||||
//
|
||||
// Built whether or not a model credential exists: the catalogue describes
|
||||
// what the tools ARE, and a deployment that cannot currently run agents can
|
||||
// still be one where somebody is authoring them.
|
||||
toolCatalogue []tools.ToolInfo
|
||||
|
||||
log *slog.Logger
|
||||
http *http.Server
|
||||
started time.Time
|
||||
endpoints int
|
||||
|
||||
// The authentication surface. sessions owns the lifecycle, users is the
|
||||
// read side of the users table, credentials verifies a password against it,
|
||||
@@ -78,6 +97,32 @@ type serverOptions struct {
|
||||
perEmail int
|
||||
perAddress int
|
||||
loginWindow time.Duration
|
||||
|
||||
// agents replaces the engine New would otherwise build from configuration.
|
||||
//
|
||||
// For tests, and only for tests: production wires a real gateway from a
|
||||
// real key, and an option that let a deployment substitute the runtime
|
||||
// would be a way to run agents against something nobody configured.
|
||||
agents *runtime.Engine
|
||||
|
||||
// version is the build identifier, stamped into the binary at link time.
|
||||
// Not configuration: it describes the artefact, not the deployment, and an
|
||||
// environment variable could disagree with the code it claims to describe.
|
||||
version string
|
||||
}
|
||||
|
||||
// WithBuildVersion records which build this is.
|
||||
//
|
||||
// Unlike the options above this one is for production. Without it there is no
|
||||
// way to answer "did my deploy land?" — the symptom is pushing an image,
|
||||
// redeploying, and having nobody, including the operator, able to tell whether
|
||||
// the running process is the new one.
|
||||
func WithBuildVersion(v string) Option {
|
||||
return func(o *serverOptions) {
|
||||
if v != "" {
|
||||
o.version = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithSessionPolicy overrides the session lifetimes. For tests that need to
|
||||
@@ -99,6 +144,20 @@ func WithClock(now func() time.Time) Option {
|
||||
//
|
||||
// perEmail bounds attempts against one account; perAddress bounds attempts from
|
||||
// one client address across all accounts. Both are consulted on every attempt.
|
||||
// WithAgentEngine substitutes the agent runtime.
|
||||
//
|
||||
// The seam that lets the HTTP layer be tested without a model credential —
|
||||
// which matters more than it sounds, because the alternative is that the run
|
||||
// endpoint is the one part of this service no test can reach until somebody
|
||||
// pays for a key.
|
||||
//
|
||||
// It does not weaken anything: the engine still loads agents through the same
|
||||
// loader, still runs them under the same budgets, and still authorizes through
|
||||
// the same principal. Only the model behind it changes.
|
||||
func WithAgentEngine(e *runtime.Engine) Option {
|
||||
return func(o *serverOptions) { o.agents = e }
|
||||
}
|
||||
|
||||
func WithLoginRateLimit(perEmail, perAddress int, window time.Duration) Option {
|
||||
return func(o *serverOptions) {
|
||||
o.perEmail, o.perAddress, o.loginWindow = perEmail, perAddress, window
|
||||
@@ -117,6 +176,7 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
|
||||
perEmail: loginAttemptLimit,
|
||||
perAddress: loginAddressLimit,
|
||||
loginWindow: loginAttemptWindow,
|
||||
version: "unknown",
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
@@ -131,10 +191,11 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
|
||||
users := auth.NewPGUserStore(database.Pool)
|
||||
s := &Server{
|
||||
cfg: cfg, db: database, log: log,
|
||||
version: o.version,
|
||||
api: service.NewRegistry(database.Pool),
|
||||
definitions: service.NewDefinitions(database.Pool),
|
||||
workflows: service.NewWorkflows(database.Pool).WithClock(o.now),
|
||||
suggestions: service.NewSuggestions(),
|
||||
suggestions: service.NewSuggestions(database.Pool),
|
||||
started: o.now(),
|
||||
sessions: sessions,
|
||||
users: users,
|
||||
@@ -144,10 +205,38 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
|
||||
now: o.now,
|
||||
}
|
||||
|
||||
// The agent runtime, wired only when there is a model to reach.
|
||||
//
|
||||
// Registering the routes without a credential would accept runs and fail
|
||||
// every one of them at the gateway — an outage shaped like a feature. A
|
||||
// deployment without a key is a deployment that does not serve agents, and
|
||||
// saying so at boot is kinder than saying it once per request.
|
||||
switch {
|
||||
case o.agents != nil:
|
||||
s.agents = o.agents
|
||||
s.runs = runtime.NewRunReader(database.Pool)
|
||||
case cfg.Model.APIKey != "":
|
||||
s.agents = runtime.NewModelEngine(database.Pool, *cfg)
|
||||
s.runs = runtime.NewRunReader(database.Pool)
|
||||
}
|
||||
|
||||
// Built the same way the runtime builds its own, so the list an author is
|
||||
// offered is the list their agent will actually have.
|
||||
toolRegistry := runtime.DefaultTools(
|
||||
database.Pool,
|
||||
knowledge.NewRetriever(database.Pool, runtime.NewEmbedder(*cfg)),
|
||||
)
|
||||
s.toolCatalogue = toolRegistry.Catalogue()
|
||||
|
||||
// So a definition naming a tool that does not exist is refused at publish
|
||||
// rather than becoming an agent that silently cannot do what it claims.
|
||||
s.definitions = s.definitions.WithToolCheck(toolRegistry.Known)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
s.endpoints = s.routeAuth(mux) + s.routeResources(mux) + s.routeMe(mux) +
|
||||
s.routeDefinitions(mux) + s.routeWorkflows(mux) + s.routeOwliver(mux)
|
||||
s.routeDefinitions(mux) + s.routeWorkflows(mux) + s.routeOwliver(mux) +
|
||||
s.routeRuns(mux) + s.routeVersion(mux) + s.routeTools(mux)
|
||||
|
||||
handler := jsonErrors(mux)
|
||||
// Authentication sits where devOrgMiddleware used to, so every route below
|
||||
@@ -227,6 +316,37 @@ type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// routeTools lists the tools an agent author may choose from.
|
||||
//
|
||||
// The frontend's agent editor had no tools field at all, so an authored agent
|
||||
// carried none and could talk without being able to look anything up. Serving
|
||||
// the catalogue rather than hard-coding it in the UI keeps one list: a tool
|
||||
// added or renamed here cannot leave a stale copy behind in a form.
|
||||
func (s *Server) routeTools(mux *http.ServeMux) int {
|
||||
mux.HandleFunc("GET /api/v1/tools", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, envelope{Data: s.toolCatalogue})
|
||||
})
|
||||
return 1
|
||||
}
|
||||
|
||||
// routeVersion exposes the build identifier to an authenticated caller.
|
||||
//
|
||||
// Under /api/v1 rather than on /health deliberately. /health is public, and it
|
||||
// already withholds its detail from the internet for the reason given above; a
|
||||
// build identifier is exactly the kind of thing that tells an unauthenticated
|
||||
// reader which source to go and read. An operator has a session, so this is
|
||||
// where an operator can reach it and a stranger cannot.
|
||||
func (s *Server) routeVersion(mux *http.ServeMux) int {
|
||||
mux.HandleFunc("GET /api/v1/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, envelope{Data: map[string]any{
|
||||
"version": s.version,
|
||||
"env": s.cfg.AppEnv,
|
||||
"endpoints": s.endpoints,
|
||||
}})
|
||||
})
|
||||
return 1
|
||||
}
|
||||
|
||||
// handleHealth reports whether this instance should be sent traffic.
|
||||
//
|
||||
// 200 "ok" serving normally
|
||||
@@ -312,6 +432,23 @@ func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Flush forwards to the writer underneath.
|
||||
//
|
||||
// A wrapper that embeds http.ResponseWriter inherits Write and WriteHeader and
|
||||
// SILENTLY DROPS every optional interface the real writer implements — Flusher
|
||||
// among them. Nothing errors: the handler simply asks "can this flush?", is
|
||||
// told no, and takes whatever fallback it has.
|
||||
//
|
||||
// That is exactly how it presented. The SSE endpoint answered ordinary JSON,
|
||||
// correctly and completely, with no error anywhere — because two middlewares
|
||||
// deep the writer had stopped being a Flusher and the streaming path politely
|
||||
// declined to stream.
|
||||
func (r *statusRecorder) Flush() {
|
||||
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func requestLogger(log *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -377,6 +514,13 @@ func (i *interceptor) Write(b []byte) (int, error) {
|
||||
return i.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Flush forwards to the writer underneath. See statusRecorder.Flush.
|
||||
func (i *interceptor) Flush() {
|
||||
if f, ok := i.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// recoverer turns a panic into a logged 500 rather than a dropped connection.
|
||||
func recoverer(log *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
||||
Reference in New Issue
Block a user